admin felület különválasztva
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/deps.py
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from typing import Optional, Dict, Any, Union, List
|
||||
import logging
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
@@ -177,6 +177,65 @@ async def get_current_admin(
|
||||
return current_user
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# RBAC Phase 1.5: Staff & Flexible Role Dependencies
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# All internal staff roles that can access the Backoffice (admin.servicefinder.hu)
|
||||
STAFF_ROLES = {
|
||||
UserRole.SUPERADMIN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.MODERATOR,
|
||||
UserRole.SALES_REP,
|
||||
UserRole.SERVICE_MGR,
|
||||
}
|
||||
|
||||
async def get_current_staff(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""
|
||||
🎯 Staff-level access dependency.
|
||||
|
||||
Admits all internal staff roles: SUPERADMIN, ADMIN, MODERATOR,
|
||||
SALES_REP, and SERVICE_MGR.
|
||||
|
||||
Use this for Backoffice (admin.servicefinder.hu) endpoints that
|
||||
should be accessible to all staff members, not just superadmins.
|
||||
"""
|
||||
if current_user.role not in STAFF_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="AUTH.STAFF_ONLY"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def RequireRole(allowed_roles: List[UserRole]):
|
||||
"""
|
||||
🎯 Flexible role-checking dependency factory.
|
||||
|
||||
Accepts a list of UserRole values. If the current user's role is
|
||||
in the list, access is granted. Otherwise, 403 Forbidden.
|
||||
|
||||
Usage:
|
||||
@router.get("/admin/sales")
|
||||
async def sales_dashboard(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.SALES_REP]))
|
||||
):
|
||||
"""
|
||||
async def role_checker(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> bool:
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="AUTH.INSUFFICIENT_PERMISSIONS"
|
||||
)
|
||||
return True
|
||||
return role_checker
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# RBAC Phase 2: Capability Dependencies (Kapuőrök)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -130,6 +130,7 @@ async def login(
|
||||
httponly=True,
|
||||
secure=True, # Use Secure
|
||||
samesite="lax",
|
||||
domain=settings.COOKIE_DOMAIN, # Cross-subdomain SSO
|
||||
max_age=max_age_sec
|
||||
)
|
||||
|
||||
@@ -224,6 +225,7 @@ async def verify_email(
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
domain=settings.COOKIE_DOMAIN, # Cross-subdomain SSO
|
||||
max_age=max_age_sec
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
Szótárak és katalógusok végpontjai.
|
||||
- GET /dictionaries/cost-categories: Költségkategóriák lekérése visibility szerint szűrve
|
||||
(P0 3D Capability Matrix: filtered by resolved org capabilities)
|
||||
- CRUD /dictionaries/insurance-providers: Biztosítók katalógusa
|
||||
"""
|
||||
import logging
|
||||
@@ -17,6 +18,7 @@ from app.schemas.fleet_finance import (
|
||||
InsuranceProviderCreate,
|
||||
InsuranceProviderUpdate,
|
||||
)
|
||||
from app.services.capability_service import resolve_org_capabilities
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,7 +33,21 @@ async def list_cost_categories(
|
||||
"""
|
||||
Költségkategóriák lekérése hierarchikus struktúrában.
|
||||
Opcionálisan szűrhető visibility alapján (pl. ha a frontend csak a 'b2c' és 'both' kategóriákat akarja).
|
||||
|
||||
P0 3D Capability Matrix:
|
||||
- Resolves the active organization's capability matrix.
|
||||
- If the user is SUPERADMIN (Fast-Pass), all categories are returned.
|
||||
- Otherwise, categories with a required_feature are only included
|
||||
if the resolved capabilities have that feature set to True.
|
||||
"""
|
||||
# ── Determine active organization ──
|
||||
active_org_id: Optional[int] = None
|
||||
if hasattr(current_user, "active_organization_id") and current_user.active_organization_id:
|
||||
active_org_id = current_user.active_organization_id
|
||||
|
||||
# ── Resolve capabilities ──
|
||||
resolved_caps = await resolve_org_capabilities(current_user, active_org_id, db)
|
||||
|
||||
stmt = select(CostCategory).order_by(CostCategory.name)
|
||||
|
||||
if visibility:
|
||||
@@ -46,6 +62,14 @@ async def list_cost_categories(
|
||||
result = await db.execute(stmt)
|
||||
categories = result.scalars().all()
|
||||
|
||||
# ── P0 3D Capability Matrix: filter by required_feature ──
|
||||
is_superadmin = resolved_caps.get("_is_superadmin", False)
|
||||
if not is_superadmin:
|
||||
categories = [
|
||||
cat for cat in categories
|
||||
if not cat.required_feature or resolved_caps.get(cat.required_feature) is True
|
||||
]
|
||||
|
||||
# Hierarchikus struktúra építése
|
||||
category_map = {}
|
||||
root_categories = []
|
||||
@@ -60,6 +84,8 @@ async def list_cost_categories(
|
||||
"visibility": cat.visibility,
|
||||
"accounting_code": cat.accounting_code,
|
||||
"is_system": cat.is_system,
|
||||
"min_tier": cat.min_tier,
|
||||
"required_feature": cat.required_feature,
|
||||
"children": [],
|
||||
}
|
||||
category_map[cat.id] = cat_dict
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from app.api.deps import get_db, get_current_user, RequireOrgCapability
|
||||
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization
|
||||
from app.schemas.asset_cost import AssetCostCreate
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -257,6 +257,100 @@ async def list_all_expenses(
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/{expense_id}", status_code=200)
|
||||
async def update_expense(
|
||||
expense_id: UUID,
|
||||
update: AssetCostUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update an existing expense record.
|
||||
|
||||
Only the fields provided in the request body will be updated.
|
||||
The expense_id is the UUID of the existing AssetCost record.
|
||||
The user must be a member of the organization that owns the expense.
|
||||
"""
|
||||
# Fetch the existing expense
|
||||
stmt = select(AssetCost).where(AssetCost.id == expense_id)
|
||||
result = await db.execute(stmt)
|
||||
cost = result.scalar_one_or_none()
|
||||
|
||||
if not cost:
|
||||
raise HTTPException(status_code=404, detail="Expense not found.")
|
||||
|
||||
# Verify user has access to the expense's organization
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.organization_id == cost.organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
membership = org_result.scalar_one_or_none()
|
||||
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You are not an active member of the organization that owns this expense."
|
||||
)
|
||||
|
||||
# Build update dict from non-None fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="No fields provided for update.")
|
||||
|
||||
# Map 'date' field to 'cost_date' column if present
|
||||
if 'date' in update_data:
|
||||
update_data['cost_date'] = update_data.pop('date')
|
||||
|
||||
# Handle mileage_at_cost and description → store in data JSONB
|
||||
data_update = {}
|
||||
if 'mileage_at_cost' in update_data:
|
||||
data_update['mileage_at_cost'] = update_data.pop('mileage_at_cost')
|
||||
if 'description' in update_data:
|
||||
data_update['description'] = update_data.pop('description')
|
||||
|
||||
# Merge data_update into existing data JSONB
|
||||
if data_update:
|
||||
existing_data = dict(cost.data or {})
|
||||
existing_data.update(data_update)
|
||||
update_data['data'] = existing_data
|
||||
|
||||
# Apply updates
|
||||
for field, value in update_data.items():
|
||||
setattr(cost, field, value)
|
||||
|
||||
try:
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(cost)
|
||||
|
||||
logger.info(f"Expense {expense_id} updated by user {current_user.id}: fields={list(update_data.keys())}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"id": cost.id,
|
||||
"asset_id": cost.asset_id,
|
||||
"category_id": cost.category_id,
|
||||
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
|
||||
"amount_net": str(cost.amount_net),
|
||||
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
|
||||
"expense_status": cost.status,
|
||||
"date": cost.date.isoformat() if cost.date else None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Expense update error for {expense_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba a költség módosításakor"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{asset_id}")
|
||||
async def list_asset_expenses(
|
||||
asset_id: UUID,
|
||||
@@ -395,30 +489,30 @@ async def create_expense(
|
||||
detail=f"DRAFT_LIMIT_REACHED: Draft vehicles are limited to {limit} expenses. This asset already has {expense_count} expenses."
|
||||
)
|
||||
|
||||
# Determine organization_id: prefer explicit from payload, fallback to asset fields
|
||||
organization_id = expense.organization_id or asset.current_organization_id or asset.owner_org_id
|
||||
if not organization_id:
|
||||
# B2C fallback: if the asset is owned by a person (not an org),
|
||||
# try to find the current user's default organization
|
||||
# P0 ARCHITECTURE FIX: organization_id MUST follow the Asset, not the user.
|
||||
# The cost belongs to the asset's current organization (e.g., Aprilia garage = org 21).
|
||||
# The vendor is stored separately via vendor_organization_id.
|
||||
#
|
||||
# Resolution priority:
|
||||
# 1. asset.current_organization_id (the asset's current fleet/garage)
|
||||
# 2. asset.owner_org_id (the asset's owning organization)
|
||||
# 3. Fallback: user's active organization from membership
|
||||
if asset.current_organization_id:
|
||||
organization_id = asset.current_organization_id
|
||||
elif asset.owner_org_id:
|
||||
organization_id = asset.owner_org_id
|
||||
else:
|
||||
# Fallback: user's active organization from membership
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.is_verified == True
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_member = org_result.scalar_one_or_none()
|
||||
if org_member:
|
||||
organization_id = org_member.organization_id
|
||||
else:
|
||||
# Last resort: any membership (even unverified)
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_member = org_result.scalar_one_or_none()
|
||||
if org_member:
|
||||
organization_id = org_member.organization_id
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
|
||||
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
|
||||
|
||||
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
|
||||
# JSONB-alapú képesség-ellenőrzés a fleet.org_roles tábla permissions oszlopából
|
||||
|
||||
@@ -778,13 +778,13 @@ async def assign_organization_subscription(
|
||||
body: SubscriptionAssignIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS))
|
||||
):
|
||||
"""
|
||||
Szervezet előfizetési csomagjának beállítása (Subscription & Package Assignment Bridge).
|
||||
|
||||
P0 Feature: This endpoint assigns a subscription_tier to an organization.
|
||||
It requires the 'can_manage_subscriptions' system capability (SUPERADMIN or ADMIN).
|
||||
It requires org-level ADMIN or OWNER access (checked via _check_org_admin_access).
|
||||
System-level SUPERADMIN capability is also accepted via the org admin check.
|
||||
|
||||
The endpoint:
|
||||
1. Validates the tier exists in system.subscription_tiers
|
||||
@@ -792,6 +792,8 @@ async def assign_organization_subscription(
|
||||
3. Updates subscription_plan and base_asset_limit from tier rules
|
||||
4. Creates/updates a finance.org_subscriptions audit record
|
||||
"""
|
||||
# Allow org ADMIN/OWNER to manage their own subscription
|
||||
await _check_org_admin_access(org_id, current_user.id, db)
|
||||
try:
|
||||
result = await upgrade_org_subscription(
|
||||
db=db,
|
||||
|
||||
@@ -187,6 +187,7 @@ async def search_service_providers(
|
||||
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
|
||||
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
|
||||
category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"),
|
||||
org_type: Optional[str] = Query(None, max_length=50, description="Szervezet típus szűrő (pl. 'service_provider', 'insurance')"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Találatok száma oldalanként"),
|
||||
offset: int = Query(0, ge=0, description="Lapozási offset"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -224,6 +225,7 @@ async def search_service_providers(
|
||||
category=category,
|
||||
city=city,
|
||||
category_ids=parsed_category_ids,
|
||||
org_type=org_type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
@@ -86,6 +86,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# --- External URLs ---
|
||||
FRONTEND_BASE_URL: str = "https://app.servicefinder.hu"
|
||||
COOKIE_DOMAIN: str = ".servicefinder.hu" # Cross-subdomain SSO cookie domain
|
||||
BACKEND_CORS_ORIGINS: List[str] = Field(
|
||||
default=[
|
||||
# Production domains
|
||||
|
||||
@@ -22,6 +22,16 @@ class SubscriptionTier(Base):
|
||||
rules: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # pl. {"max_vehicles": 5}
|
||||
is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# ── P0 3D CAPABILITY MATRIX: feature_capabilities JSONB ──
|
||||
# Defines what features/capabilities this subscription tier unlocks.
|
||||
# Example: {"can_export_data": True, "can_use_analytics": True, "max_vehicles": 50}
|
||||
feature_capabilities: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
server_default=text("'{}'::jsonb")
|
||||
)
|
||||
|
||||
class OrganizationSubscription(Base):
|
||||
"""
|
||||
Szervezetek aktuális előfizetései és azok érvényessége.
|
||||
|
||||
@@ -56,6 +56,17 @@ class CostCategory(Base):
|
||||
visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'")
|
||||
min_tier: Mapped[str] = mapped_column(String(20), default="free", server_default="'free'")
|
||||
accounting_code: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# ── P0 3D CAPABILITY MATRIX: required_feature ──
|
||||
# If set, this category is ONLY visible to organizations whose resolved
|
||||
# capability matrix has this feature key set to True.
|
||||
# Example: "can_use_analytics" → only orgs with that capability see this category.
|
||||
required_feature: Mapped[Optional[str]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
comment="Feature key required to access this cost category"
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
|
||||
@@ -151,6 +151,17 @@ class Organization(Base):
|
||||
index=True
|
||||
)
|
||||
|
||||
# ── P0 3D CAPABILITY MATRIX: custom_features JSONB ──
|
||||
# Organization-specific feature overrides on top of the subscription tier defaults.
|
||||
# These values MERGE with (and override) tier.feature_capabilities at runtime.
|
||||
# Example: {"can_export_data": True, "can_use_analytics": False}
|
||||
custom_features: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
server_default=text("'{}'::jsonb")
|
||||
)
|
||||
|
||||
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
|
||||
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
|
||||
@@ -208,6 +219,14 @@ class Organization(Base):
|
||||
|
||||
# Kapcsolat az örök személy rekordhoz
|
||||
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
|
||||
|
||||
# ── P0 3D CAPABILITY MATRIX: SubscriptionTier relationship ──
|
||||
subscription_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
|
||||
"SubscriptionTier",
|
||||
foreign_keys=[subscription_tier_id],
|
||||
backref="organizations",
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
|
||||
class OrganizationFinancials(Base):
|
||||
|
||||
@@ -81,6 +81,30 @@ class AssetCostCreate(AssetCostBase):
|
||||
status: Optional[str] = Field(None, description="Költség státusz (DRAFT, PENDING_APPROVAL, APPROVED) - auto-set by backend")
|
||||
linked_asset_event_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetEvent ID (kétirányú hivatkozáshoz)")
|
||||
|
||||
|
||||
class AssetCostUpdate(BaseModel):
|
||||
"""Schema for updating an existing expense via PUT /expenses/{expense_id}.
|
||||
|
||||
All fields are optional — only provided fields will be updated.
|
||||
The expense_id is passed via path parameter, asset_id and organization_id
|
||||
are immutable after creation.
|
||||
"""
|
||||
category_id: Optional[int] = None
|
||||
amount_gross: Optional[Decimal] = None
|
||||
amount_net: Optional[Decimal] = None
|
||||
vat_rate: Optional[Decimal] = None
|
||||
currency: Optional[str] = None
|
||||
date: Optional[datetime] = None
|
||||
mileage_at_cost: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
invoice_number: Optional[str] = None
|
||||
vendor_organization_id: Optional[int] = None
|
||||
external_vendor_name: Optional[str] = None
|
||||
invoice_date: Optional[date] = None
|
||||
fulfillment_date: Optional[date] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AssetCostResponse(AssetCostBase):
|
||||
"""Response schema — matches DB columns from vehicle.asset_costs."""
|
||||
id: UUID
|
||||
|
||||
109
backend/app/services/capability_service.py
Normal file
109
backend/app/services/capability_service.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
P0 3D Capability Matrix — Capability Resolver Service
|
||||
|
||||
Resolves the effective feature capabilities for a given organization by merging:
|
||||
1. The SubscriptionTier's feature_capabilities (tier defaults)
|
||||
2. The Organization's custom_features (org-specific overrides)
|
||||
|
||||
Superadmin "Fast-Pass": If the user's role is SUPERADMIN, all capabilities
|
||||
are bypassed and a special {"_is_superadmin": True} marker is returned.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.models.identity import User, UserRole
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def resolve_org_capabilities(
|
||||
user: User,
|
||||
org_id: Optional[int],
|
||||
db: AsyncSession,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolve the effective 3D capability matrix for a user within an organization.
|
||||
|
||||
Business Logic:
|
||||
1. Fast-Pass: If user.role is SUPERADMIN, return {"_is_superadmin": True}.
|
||||
2. If no org_id is provided, return an empty dict (no capabilities).
|
||||
3. Fetch the Organization by org_id, joined with its SubscriptionTier.
|
||||
4. tier_features = tier.feature_capabilities (or {} if None/missing).
|
||||
5. org_features = org.custom_features (or {} if None/missing).
|
||||
6. Merge: {**tier_features, **org_features} (org overrides tier).
|
||||
7. Return the merged dict.
|
||||
|
||||
Args:
|
||||
user: The authenticated User instance.
|
||||
org_id: The active organization ID (may be None).
|
||||
db: Async SQLAlchemy session.
|
||||
|
||||
Returns:
|
||||
A dict of resolved capability flags.
|
||||
"""
|
||||
# ── Fast-Pass: SUPERADMIN bypasses everything ──
|
||||
if user.role == UserRole.SUPERADMIN:
|
||||
logger.debug(
|
||||
"Capability Fast-Pass for user=%s (SUPERADMIN)",
|
||||
user.id,
|
||||
)
|
||||
return {"_is_superadmin": True}
|
||||
|
||||
# ── No active organization → no capabilities ──
|
||||
if org_id is None:
|
||||
logger.debug(
|
||||
"No active org for user=%s, returning empty capabilities",
|
||||
user.id,
|
||||
)
|
||||
return {}
|
||||
|
||||
# ── Fetch Organization + SubscriptionTier ──
|
||||
stmt = (
|
||||
select(Organization)
|
||||
.options(
|
||||
joinedload(Organization.subscription_tier)
|
||||
)
|
||||
.where(Organization.id == org_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
org = result.unique().scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
logger.warning(
|
||||
"Organization id=%s not found for user=%s, returning empty capabilities",
|
||||
org_id,
|
||||
user.id,
|
||||
)
|
||||
return {}
|
||||
|
||||
# ── Extract tier features ──
|
||||
tier_features: Dict[str, Any] = {}
|
||||
if org.subscription_tier_id is not None:
|
||||
# The subscription_tier is loaded via joinedload
|
||||
tier = org.subscription_tier # type: Optional[SubscriptionTier]
|
||||
if tier and tier.feature_capabilities:
|
||||
tier_features = dict(tier.feature_capabilities)
|
||||
|
||||
# ── Extract org custom features ──
|
||||
org_features: Dict[str, Any] = {}
|
||||
if org.custom_features:
|
||||
org_features = dict(org.custom_features)
|
||||
|
||||
# ── Merge: org custom_features override tier defaults ──
|
||||
merged = {**tier_features, **org_features}
|
||||
|
||||
logger.debug(
|
||||
"Resolved capabilities for org=%s user=%s: %s",
|
||||
org_id,
|
||||
user.id,
|
||||
merged,
|
||||
)
|
||||
|
||||
return merged
|
||||
@@ -141,6 +141,7 @@ async def search_providers(
|
||||
category: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
category_ids: Optional[List[int]] = None,
|
||||
org_type: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> ProviderSearchResponse:
|
||||
@@ -174,6 +175,7 @@ async def search_providers(
|
||||
category: Kategória szűrő
|
||||
city: Város szűrő — AND kapcsolat a q-val
|
||||
category_ids: Kategória ID-k listája (SZIGORÚ AND szűrés)
|
||||
org_type: Szervezet típus szűrő (pl. 'service_provider', 'insurance')
|
||||
limit: Lapozási limit
|
||||
offset: Lapozási offset
|
||||
|
||||
@@ -190,9 +192,12 @@ async def search_providers(
|
||||
|
||||
# --- 1. LEKÉRDEZÉS: Szervezetek (fleet.organizations) ---
|
||||
org_conditions = [
|
||||
Organization.org_type == OrgType.service_provider,
|
||||
Organization.is_deleted == False,
|
||||
]
|
||||
if org_type:
|
||||
org_conditions.append(Organization.org_type == org_type)
|
||||
else:
|
||||
org_conditions.append(Organization.org_type == OrgType.service_provider)
|
||||
if q_tokens:
|
||||
token_conditions = []
|
||||
for token in q_tokens:
|
||||
@@ -630,25 +635,10 @@ async def quick_add_provider(
|
||||
db.add(branch)
|
||||
|
||||
# =====================================================================
|
||||
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (P0 CRITICAL BUGFIX 2026-06-17)
|
||||
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (KIHAGYVA - P0 ARCHITECTURE FIX)
|
||||
# =====================================================================
|
||||
# A létrehozó felhasználó ADMIN szerepkörű tag lesz (NEM OWNER).
|
||||
# Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL).
|
||||
# Az ADMIN jogosultság elegendő a későbbi szerkesztéshez, de a cég
|
||||
# nem jelenik meg a "Cégeim" garázsválasztóban.
|
||||
member = OrganizationMember(
|
||||
organization_id=org.id,
|
||||
user_id=user_id,
|
||||
role=OrgUserRole.ADMIN,
|
||||
is_permanent=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(member)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"OrganizationMember created: org_id={org.id}, user_id={user_id}, "
|
||||
f"role=ADMIN (crowdsourced provider has no owner)"
|
||||
)
|
||||
# DÖNTÉS: A létrehozó felhasználó NEM lehet tagja a beszállító szervezetnek.
|
||||
# A szerviz jöjjön létre owner_id=None beállítással, tagok nélkül.
|
||||
# =====================================================================
|
||||
|
||||
# =====================================================================
|
||||
|
||||
@@ -79,6 +79,36 @@ COST_CATEGORY_TREE = [
|
||||
("SERVICE_TIRES", "Gumiabroncs", "Gumiabroncs csere és tárolás"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "TIRE",
|
||||
"name": "Gumiabroncsok",
|
||||
"description": "Gumiabroncsokkal kapcsolatos költségek",
|
||||
"children": [
|
||||
("TIRE_SUMMER", "Nyári gumi", "Nyári gumiabroncs vásárlás"),
|
||||
("TIRE_WINTER", "Téli gumi", "Téli gumiabroncs vásárlás"),
|
||||
("TIRE_STORAGE", "Gumi tárolás", "Gumiabroncs tárolási díj"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "CLEANING",
|
||||
"name": "Ápolás & Kozmetika",
|
||||
"description": "Jármű ápolásával és kozmetikájával kapcsolatos költségek",
|
||||
"children": [
|
||||
("CLEANING_WASH", "Mosás", "Járműmosás"),
|
||||
("CLEANING_DETAILING", "Részletes tisztítás", "Részletes autókozmetika"),
|
||||
("CLEANING_INTERIOR", "Belső tisztítás", "Belső tér tisztítása"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "OTHER",
|
||||
"name": "Egyéb",
|
||||
"description": "Egyéb, máshova nem sorolható költségek",
|
||||
"children": [
|
||||
("OTHER_FINE", "Bírság", "Közlekedési bírság"),
|
||||
("OTHER_ACCESSORY", "Tartozék", "Jármű tartozékok"),
|
||||
("OTHER_MISC", "Egyéb", "Egyéb költségek"),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user