""" 🏱 Admin Garages (Organizations) CRM API VĂ©gpontok: GET /admin/organizations — GarĂĄzsok listĂĄzĂĄsa elƑfizetĂ©si adatokkal GET /admin/organizations/{org_id}/details — GarĂĄzs rĂ©szletes adatai (General Tab) PATCH /admin/organizations/{org_id}/subscription — ElƑfizetĂ©s mĂłdosĂ­tĂĄsa POST /admin/organizations/{org_id}/members — Új tag hozzĂĄadĂĄsa a garĂĄzshoz PUT /admin/organizations/{org_id}/members/{member_id} — Tag adatainak mĂłdosĂ­tĂĄsa DELETE /admin/organizations/{org_id}/members/{member_id} — Tag eltĂĄvolĂ­tĂĄsa """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select, func, or_, update as sa_update, delete as sa_delete from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload, joinedload from pydantic import BaseModel, Field from app.api import deps from app.db.session import get_db from app.models.core_logic import SubscriptionTier, OrganizationSubscription from app.models.marketplace.organization import Organization, OrganizationMember from app.models.fleet.organization import ContactPerson from app.models.identity import User, Person from app.models.vehicle.history import AuditLog, LogSeverity from app.schemas.organization import ( OrganizationMemberCreate, OrganizationMemberUpdate, OrganizationMemberResponse, PersonBrief, ) logger = logging.getLogger("admin-organizations") router = APIRouter() # ============================================================================= # Pydantic Schemas # ============================================================================= class OrgSubscriptionUpdate(BaseModel): """ElƑfizetĂ©s mĂłdosĂ­tĂĄsĂĄnak kĂ©rĂ©se. KĂ©t ĂŒzemmĂłd: - Mode A (tier_id megadva): Teljes elƑfizetĂ©s vĂĄltĂĄs (Ășj tier, Ășj subscription sor). - Mode B (tier_id = None): Csak az add-on (extra_allowances) frissĂ­tĂ©se a meglĂ©vƑ aktĂ­v elƑfizetĂ©sen. Ekkor a tier_id, expires_at mezƑk figyelmen kĂ­vĂŒl maradnak. P0 ARCHITECTURE SHIFT (Itemized Add-ons): - Mode B most mĂĄr ITEMIZÁLT: minden add-on tĂ­pushoz kĂŒlön OrganizationSubscription sort hoz lĂ©tre/frissĂ­t a finance.org_subscriptions tĂĄblĂĄban, sajĂĄt valid_until-lal. - A rĂ©gi extra_vehicles/extra_branches/extra_users mezƑk tovĂĄbbra is tĂĄmogatottak a frontend kompatibilitĂĄs miatt, de a belsƑ logika itemizĂĄlt sorokkĂĄ alakĂ­tja Ƒket. """ tier_id: Optional[int] = Field( default=None, description="Az Ășj SubscriptionTier ID-ja. Ha None (Mode B), csak az add-on-ok " "frissĂŒlnek a meglĂ©vƑ aktĂ­v elƑfizetĂ©sen." ) expires_at: Optional[datetime] = Field( default=None, description="Egyedi lejĂĄrati dĂĄtum (pl. 2-5 Ă©ves B2B deal esetĂ©n). " "Ha None, a csomag duration.days alapjĂĄn szĂĄmolĂłdik." ) # ── P0 ADD-ON UPGRADE: Extra allowances (add-ons) ── extra_vehicles: int = Field( default=0, ge=0, description="Extra jĂĄrmƱ kvĂłta a csomag keretein felĂŒl (add-on)." ) extra_branches: int = Field( default=0, ge=0, description="Extra telephely kvĂłta a csomag keretein felĂŒl (add-on)." ) extra_users: int = Field( default=0, ge=0, description="Extra dolgozĂł kvĂłta a csomag keretein felĂŒl (add-on)." ) # ── P0 ITEMIZED ADD-ON: Individual expiration dates per add-on type ── extra_vehicles_expires_at: Optional[datetime] = Field( default=None, description="Egyedi lejĂĄrati dĂĄtum az Extra JĂĄrmƱ add-on-hoz. " "DĂĄtum vagy datetime; a backend 23:59:59-re normalizĂĄlja." ) extra_branches_expires_at: Optional[datetime] = Field( default=None, description="Egyedi lejĂĄrati dĂĄtum az Extra Telephely add-on-hoz. " "DĂĄtum vagy datetime; a backend 23:59:59-re normalizĂĄlja." ) extra_users_expires_at: Optional[datetime] = Field( default=None, description="Egyedi lejĂĄrati dĂĄtum az Extra DolgozĂł add-on-hoz. " "DĂĄtum vagy datetime; a backend 23:59:59-re normalizĂĄlja." ) class OrgSubscriptionResponse(BaseModel): """ElƑfizetĂ©s adatainak vĂĄlasza.""" id: int org_id: int tier_id: int tier_name: str = "" valid_from: Optional[str] = None valid_until: Optional[str] = None is_active: bool = True extra_allowances: dict = {} class GarageListItem(BaseModel): """Egy garĂĄzs adatai a listĂĄban.""" id: int name: str full_name: str display_name: Optional[str] = None email: Optional[str] = None status: str is_active: bool is_verified: bool is_deleted: bool org_type: str city: Optional[str] = None region: Optional[str] = None segment: Optional[str] = None member_count: int = 0 created_at: Optional[str] = None # ElƑfizetĂ©si adatok subscription: Optional[OrgSubscriptionResponse] = None subscription_tier_name: str = "Free/Fallback" subscription_expires_at: Optional[str] = None class GarageListResponse(BaseModel): """GarĂĄzsok listĂĄjĂĄnak vĂĄlasza.""" total: int skip: int limit: int garages: List[GarageListItem] # ============================================================================= # Pydantic Schemas — Garage Details # ============================================================================= class ContactPersonInfo(BaseModel): """KapcsolattartĂł szemĂ©ly adatai.""" id: int full_name: str = "" role: str = "" department: Optional[str] = None phone: Optional[str] = None email: Optional[str] = None is_primary: bool = False class AddonInfo(BaseModel): """P0 ITEMIZED ADD-ON: Egy add-on sor adatai.""" type: str quantity: int subscription_id: Optional[int] = None valid_until: Optional[str] = None expires_at: Optional[str] = None tier_name: Optional[str] = None class SubscriptionSummary(BaseModel): """ElƑfizetĂ©s összefoglalĂł.""" tier_name: str = "Free/Fallback" tier_level: int = 0 tier_id: Optional[int] = None valid_from: Optional[str] = None expires_at: Optional[str] = None is_active: bool = True asset_count: int = 0 asset_limit: int = 1 # ── P0 ADD-ON UPGRADE: Branch & User limits ── branch_limit: int = 0 user_limit: int = 0 # ── P0 ADD-ON UPGRADE: Extra allowances (add-ons) ── extra_allowances: dict = {} # ── P0 ITEMIZED ADD-ON: Individual add-on rows ── addons: List[AddonInfo] = [] class BranchBrief(BaseModel): """Rövid telephely adatok a frontend szĂĄmĂĄra.""" id: str name: str city: Optional[str] = None is_active: bool = True class GarageDetailsResponse(BaseModel): """GarĂĄzs rĂ©szletes adatai (General Tab) — kibƑvĂ­tett CRM profil.""" # Szervezet adatok id: int name: str full_name: str display_name: Optional[str] = None status: str is_active: bool is_verified: bool org_type: str # CĂ­m adatok (elsƑdleges) address_city: Optional[str] = None address_zip: Optional[str] = None address_street_name: Optional[str] = None address_street_type: Optional[str] = None address_house_number: Optional[str] = None tax_number: Optional[str] = None reg_number: Optional[str] = None # ── KapcsolattartĂł (Contact Person) ── contact_person_name: Optional[str] = None contact_email: Optional[str] = None contact_phone: Optional[str] = None # ── SzĂĄmlĂĄzĂĄsi cĂ­m (Billing Address) ── billing_zip: Optional[str] = None billing_city: Optional[str] = None billing_street_name: Optional[str] = None billing_street_type: Optional[str] = None billing_house_number: Optional[str] = None # ── ÉrtesĂ­tĂ©si cĂ­m (Notification Address) ── notification_zip: Optional[str] = None notification_city: Optional[str] = None notification_street_name: Optional[str] = None notification_street_type: Optional[str] = None notification_house_number: Optional[str] = None # ElƑfizetĂ©s subscription: Optional[SubscriptionSummary] = None # KapcsolattartĂł (ContactPerson modellbƑl) primary_contact: Optional[ContactPersonInfo] = None # Meta created_at: Optional[str] = None member_count: int = 0 # P0: Owner user ID for frontend display owner_user_id: Optional[int] = None # P0: Owner person ID for frontend display owner_person_id: Optional[int] = None # P0: Full member list with nested person data members: List[OrganizationMemberResponse] = [] # ── P0 ADD-ON UPGRADE: Branches list for utilization display ── branches: List[BranchBrief] = [] # ── P0 AUDIT TRAIL: Subscription history from audit.audit_logs ── subscription_history: List["SubscriptionHistoryItem"] = [] class SubscriptionHistoryItem(BaseModel): """Egy elƑfizetĂ©s-mĂłdosĂ­tĂĄsi esemĂ©ny az audit naplĂłbĂłl.""" id: int action: str admin_user_id: Optional[int] = None old_data: Optional[Dict[str, Any]] = None new_data: Optional[Dict[str, Any]] = None timestamp: Optional[str] = None # Denormalized display fields tier_name: Optional[str] = None changes_summary: Optional[str] = None # ============================================================================= # GET / — GarĂĄzsok listĂĄzĂĄsa # ============================================================================= @router.get( "", response_model=GarageListResponse, summary="GarĂĄzsok listĂĄzĂĄsa", description="Visszaadja az összes szervezetet (garĂĄzst) elƑfizetĂ©si adatokkal Ă©s taglĂ©tszĂĄmmal.", ) async def list_organizations( skip: int = Query(0, ge=0, description="HĂĄny rekordot hagyjunk ki (lapozĂĄs)"), limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok szĂĄma"), search_term: Optional[str] = Query(None, description="KeresĂ©s nĂ©v vagy e-mail alapjĂĄn"), status_filter: Optional[str] = Query(None, description="SzƱrĂ©s stĂĄtusz szerint (active/inactive/suspended/pending_verification)"), org_type_filter: Optional[str] = Query(None, description="SzƱrĂ©s szervezet tĂ­pus szerint"), tier_name_filter: Optional[str] = Query(None, description="SzƱrĂ©s elƑfizetĂ©si csomag neve alapjĂĄn (pl. 'Premium', 'Free/Fallback')"), db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:view")), ) -> GarageListResponse: """ GarĂĄzsok / Szervezetek listĂĄzĂĄsa lapozĂĄssal Ă©s keresĂ©ssel. Minden garĂĄzshoz visszaadja: - Alapadatokat (nĂ©v, stĂĄtusz, tĂ­pus, vĂĄros) - TaglĂ©tszĂĄmot (OrganizationMember-ek szĂĄma) - AktĂ­v elƑfizetĂ©s adatait (tier nĂ©v, lejĂĄrati dĂĄtum) """ # Alap lekĂ©rdezĂ©s — outerjoin owner (User) hogy a tulajdonos emailjĂ©t is kereshessĂŒk base_query = select(Organization.id).outerjoin(Organization.owner) # SzƱrĂ©s if search_term: like_pattern = f"%{search_term}%" base_query = base_query.where( Organization.full_name.ilike(like_pattern) | Organization.name.ilike(like_pattern) | Organization.display_name.ilike(like_pattern) | Organization.contact_email.ilike(like_pattern) | Organization.contact_person_name.ilike(like_pattern) | User.email.ilike(like_pattern) ) if status_filter: base_query = base_query.where(Organization.status == status_filter) if org_type_filter: if org_type_filter == "corporate": # P0 HOTFIX: "corporate" is a UI category, not a DB enum value. # The DB enum (fleet.orgtype) has: individual, business, fleet_owner, # service, service_provider, club. "corporate" means all non-individual types. from app.models.marketplace.organization import OrgType corporate_types = [ t.value for t in OrgType if t.value != OrgType.individual.value ] base_query = base_query.where(Organization.org_type.in_(corporate_types)) else: base_query = base_query.where(Organization.org_type == org_type_filter) if tier_name_filter: from app.models.core_logic import SubscriptionTier base_query = base_query.join(SubscriptionTier, Organization.subscription_tier_id == SubscriptionTier.id) base_query = base_query.where(SubscriptionTier.name.ilike(f"%{tier_name_filter}%")) # Teljes szĂĄmolĂĄs count_query = select(func.count()).select_from(base_query.subquery()) total_result = await db.execute(count_query) total = total_result.scalar() or 0 # LapozĂĄs — get IDs first, then fetch full objects id_query = base_query.order_by(Organization.id.desc()).offset(skip).limit(limit) id_result = await db.execute(id_query) org_ids_subset = [row[0] for row in id_result.all()] # Fetch full Organization objects by IDs with joinedload to avoid MissingGreenlet if org_ids_subset: from sqlalchemy.orm import joinedload query = select(Organization).options( joinedload(Organization.subscription_tier), joinedload(Organization.owner), ).where(Organization.id.in_(org_ids_subset)) result = await db.execute(query) # Use unique() because joinedload may produce duplicate rows organizations = result.unique().scalars().all() # Preserve the ordering from the id_query org_map = {org.id: org for org in organizations} organizations = [org_map[oid] for oid in org_ids_subset if oid in org_map] else: organizations = [] # Összes org_id begyƱjtĂ©se a tömeges subscription lekĂ©rdezĂ©shez org_ids = [org.id for org in organizations] # Tömeges subscription lekĂ©rdezĂ©s subscriptions_map: Dict[int, OrganizationSubscription] = {} if org_ids: sub_stmt = ( select(OrganizationSubscription) .options(selectinload(OrganizationSubscription.tier)) .where( OrganizationSubscription.org_id.in_(org_ids), OrganizationSubscription.is_active == True, ) ) sub_result = await db.execute(sub_stmt) for sub in sub_result.scalars().all(): # Ha több subscription is van, a legutolsĂłt vegyĂŒk if sub.org_id not in subscriptions_map: subscriptions_map[sub.org_id] = sub # Tömeges taglĂ©tszĂĄm lekĂ©rdezĂ©s member_counts: Dict[int, int] = {} if org_ids: from sqlalchemy import func as sa_func member_stmt = ( select( OrganizationMember.organization_id, sa_func.count(OrganizationMember.id) ) .where( OrganizationMember.organization_id.in_(org_ids), OrganizationMember.status == "active", ) .group_by(OrganizationMember.organization_id) ) member_result = await db.execute(member_stmt) for row in member_result.all(): member_counts[row[0]] = row[1] # VĂĄlasz összeĂĄllĂ­tĂĄsa garage_list = [] for org in organizations: sub = subscriptions_map.get(org.id) sub_response = None tier_name = "Free/Fallback" expires_at = None if sub and sub.tier: tier_name = sub.tier.name expires_at = sub.valid_until.isoformat() if sub.valid_until else None sub_response = OrgSubscriptionResponse( id=sub.id, org_id=sub.org_id, tier_id=sub.tier_id, tier_name=sub.tier.name, valid_from=sub.valid_from.isoformat() if sub.valid_from else None, valid_until=expires_at, is_active=sub.is_active, extra_allowances=sub.extra_allowances or {}, ) elif org.subscription_tier: # Fallback: Organization.subscription_tier_id kapcsolat tier_name = org.subscription_tier.name expires_at = org.subscription_expires_at.isoformat() if org.subscription_expires_at else None # Resolve email: prefer contact_email, fallback to owner's email resolved_email = org.contact_email or (org.owner.email if org.owner else None) garage_list.append(GarageListItem( id=org.id, name=org.name, full_name=org.full_name, display_name=org.display_name, email=resolved_email, status=org.status, is_active=org.is_active, is_verified=org.is_verified, is_deleted=org.is_deleted, org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type), city=org.address_city, region=org.region, segment=org.segment, member_count=member_counts.get(org.id, 0), created_at=org.created_at.isoformat() if org.created_at else None, subscription=sub_response, subscription_tier_name=tier_name, subscription_expires_at=expires_at, )) return GarageListResponse( total=total, skip=skip, limit=limit, garages=garage_list, ) # ============================================================================= # P0 ARCHITECTURE UNIFICATION: _extract_limit() helper # ============================================================================= def _extract_limit( rules: dict, keys: list[str], default: int = 0, ) -> int: """ P0 ARCHITECTURE UNIFICATION: Egyetlen, robusztus fĂŒggvĂ©ny a limit Ă©rtĂ©kek kinyerĂ©sĂ©re a SubscriptionTier.rules JSONB objektumbĂłl. KĂ©t rĂ©tegben keres: 1. KözvetlenĂŒl a rules objektumban: rules["max_vehicles"] 2. A rules["allowances"] nested objektumban: rules["allowances"]["max_vehicles"] Ez biztosĂ­tja, hogy a JSONB struktĂșra vĂĄltozĂĄsai (pl. 'allowances' nested object) ne törjĂ©k meg a limit szĂĄmĂ­tĂĄsokat. Args: rules: A SubscriptionTier.rules JSONB dict-je. keys: A keresendƑ kulcsok listĂĄja (pl. ["max_vehicles", "max_assets"]). Az elsƑ talĂĄlat Ă©rtĂ©ke kerĂŒl visszaadĂĄsra. default: AlapĂ©rtelmezett Ă©rtĂ©k, ha egyik kulcs sem talĂĄlhatĂł. Returns: int: A talĂĄlt limit Ă©rtĂ©k, vagy a default. """ if not rules or not isinstance(rules, dict): return default # 1. szint: Közvetlen keresĂ©s a rules objektumban for key in keys: value = rules.get(key) if value is not None and isinstance(value, (int, float)): return int(value) # 2. szint: KeresĂ©s a rules["allowances"] nested objektumban allowances = rules.get("allowances") if allowances and isinstance(allowances, dict): for key in keys: value = allowances.get(key) if value is not None and isinstance(value, (int, float)): return int(value) # 3. szint: KeresĂ©s a rules["limits"] nested objektumban (alternatĂ­v nĂ©v) limits = rules.get("limits") if limits and isinstance(limits, dict): for key in keys: value = limits.get(key) if value is not None and isinstance(value, (int, float)): return int(value) return default # ============================================================================= # P0 ARCHITECTURE SHIFT: normalize_to_end_of_day() helper # ============================================================================= def normalize_to_end_of_day(dt: Optional[datetime]) -> Optional[datetime]: """ P0 ARCHITECTURE SHIFT: Minden valid_until Ă©rtĂ©ket nap vĂ©gĂ©re (23:59:59) normalizĂĄl. Ez biztosĂ­tja, hogy a lejĂĄrati dĂĄtumok konzisztensen viselkedjenek: - Ha a user 2026-12-31-et ad meg, az 2026-12-31 23:59:59 UTC lesz. - Ha a datetime mĂĄr tartalmaz idƑt, azt felĂŒlĂ­rjuk 23:59:59-re. - Ha None, visszatĂ©rĂŒnk None-nal. TĂĄmogatja a datetime Ă©s date objektumokat is: - datetime: közvetlenĂŒl normalizĂĄlja - date: elƑbb datetime-vĂ© alakĂ­tja (Ă©jjel 00:00:00), majd normalizĂĄlja 23:59:59-re Args: dt: A normalizĂĄlandĂł datetime vagy date (lehet timezone-aware vagy naive). Returns: Optional[datetime]: A nap vĂ©gĂ©re normalizĂĄlt datetime (timezone-aware UTC), vagy None ha a bemenet is None. """ if dt is None: return None # Ha date objektum, alakĂ­tsuk datetime-vĂ© if not isinstance(dt, datetime): from datetime import date as date_type if isinstance(dt, date_type): dt = datetime(dt.year, dt.month, dt.day, tzinfo=timezone.utc) # Ha naive, tegyĂŒk UTC-vĂ© if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.replace(hour=23, minute=59, second=59, microsecond=0) # ============================================================================= # P0 ARCHITECTURE SHIFT: _find_or_create_addon_tier() helper # ============================================================================= ADDON_TIER_NAMES = { "extra_vehicles": "Extra Vehicles", "extra_branches": "Extra Branches", "extra_users": "Extra Users", } async def _find_or_create_addon_tier( db: AsyncSession, addon_type: str, ) -> Optional[int]: """ P0 ARCHITECTURE SHIFT: Megkeresi vagy lĂ©trehozza az add-on SubscriptionTier-t. Minden add-on tĂ­pushoz ('extra_vehicles', 'extra_branches', 'extra_users') lĂ©teznie kell egy dedikĂĄlt SubscriptionTier-nek 'addon' tĂ­pussal. Ha nem lĂ©tezik, automatikusan lĂ©trehozza. Args: db: Az adatbĂĄzis session. addon_type: Az add-on tĂ­pus neve (pl. 'extra_vehicles'). Returns: Optional[int]: A SubscriptionTier ID-ja, vagy None ha nem sikerĂŒlt. """ display_name = ADDON_TIER_NAMES.get(addon_type, addon_type.replace("_", " ").title()) # 1. PrĂłbĂĄljuk megkeresni a meglĂ©vƑ add-on tier-t stmt = select(SubscriptionTier).where( SubscriptionTier.type == "addon", SubscriptionTier.name == addon_type, ) result = await db.execute(stmt) tier = result.scalar_one_or_none() if tier: return tier.id # 2. Ha nem lĂ©tezik, hozzuk lĂ©tre new_tier = SubscriptionTier( name=addon_type, type="addon", tier_level=0, is_custom=True, rules={ "display_name": display_name, "allowances": {f"max_{addon_type.replace('extra_', '')}": 0}, }, feature_capabilities={}, ) db.add(new_tier) await db.flush() logger.info(f"P0: Auto-created add-on tier '{addon_type}' (id={new_tier.id})") return new_tier.id # ============================================================================= # GET /{org_id}/details — GarĂĄzs rĂ©szletes adatai (General Tab) # ============================================================================= @router.get( "/{org_id}/details", response_model=GarageDetailsResponse, summary="GarĂĄzs rĂ©szletes adatai", description="Visszaadja egy garĂĄzs teljes adatait, elƑfizetĂ©si összefoglalĂłjĂĄt, " "elsƑdleges kapcsolattartĂłjĂĄt Ă©s taglistĂĄjĂĄt a General Tab szĂĄmĂĄra.", ) async def get_organization_details( org_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:view")), ) -> GarageDetailsResponse: """ GarĂĄzs rĂ©szletes adatainak lekĂ©rĂ©se. Visszaadja: - Teljes szervezet adatokat (nĂ©v, cĂ­m, adĂłszĂĄm, stĂĄtusz) - ElƑfizetĂ©s összefoglalĂłt (tier nĂ©v, lejĂĄrat, jĂĄrmƱszĂĄm/korlĂĄt) - ElsƑdleges kapcsolattartĂł adatait (fleet.contact_persons) - Tagok teljes listĂĄjĂĄt (OrganizationMember) nested Person adatokkal P0 Zero-Duplication: - Ha contact_email/phone/person_name NULL, az owner adatai kerĂŒlnek bele. - Az owner mindig szerepel a members listĂĄban (OWNER role). """ # 1. Szervezet lekĂ©rĂ©se subscription_tier kapcsolattal + owner org_stmt = ( select(Organization) .options( selectinload(Organization.subscription_tier), selectinload(Organization.owner).selectinload(User.person), ) .where(Organization.id == org_id) ) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem talĂĄlhatĂł ID-vel: {org_id}", ) # 2. AktĂ­v elƑfizetĂ©sek lekĂ©rĂ©se (P0 ARCHITECTURE UNIFICATION: .all() instead of .limit(1)) # Több subscription is lehet (1 Base + N Add-on). Minden aktĂ­v sort lekĂ©rĂŒnk, # majd aggregĂĄljuk a limit Ă©rtĂ©keket. # P0 CRITICAL FIX: Add-on subscriptions may have valid_until=NULL (indefinite), # so we must use OR condition: valid_until >= now OR valid_until IS NULL. # P0 CRITICAL FIX: Use timezone-aware UTC now() because valid_until is # DateTime(timezone=True). Comparing naive datetime with timezone-aware # timestamp causes PostgreSQL to return no results. now = datetime.now(timezone.utc) sub_stmt = ( select(OrganizationSubscription) .options(selectinload(OrganizationSubscription.tier)) .where( OrganizationSubscription.org_id == org_id, OrganizationSubscription.is_active == True, or_( OrganizationSubscription.valid_until >= now, OrganizationSubscription.valid_until.is_(None), ), ) .order_by(OrganizationSubscription.id.desc()) ) sub_result = await db.execute(sub_stmt) active_subs = sub_result.scalars().all() # 2b. P0 ADD-ON: ValĂłs jĂĄrmƱszĂĄm lekĂ©rĂ©se a fleet tĂĄblĂĄbĂłl # P0 CRITICAL FIX: Exclude archived/draft vehicles from quota count. # Only count vehicles with status='active' to prevent quota abuse. # DB schema (vehicle.assets): status values are 'active', 'archived', 'draft'. # 'archived' = soft-deleted, 'draft' = incomplete registration. from app.models.vehicle import Asset # noqa: E402 vehicle_count_stmt = select(func.count(Asset.id)).where( Asset.current_organization_id == org_id, Asset.status == 'active', ) vehicle_count_result = await db.execute(vehicle_count_stmt) asset_count = vehicle_count_result.scalar() or 0 # 2c. P0 ADD-ON: Telephelyek (branches) lekĂ©rĂ©se from app.models.marketplace.organization import Branch # noqa: E402 branches_stmt = select(Branch).where( Branch.organization_id == org_id, Branch.is_deleted == False, ).order_by(Branch.name) branches_result = await db.execute(branches_stmt) branch_rows = branches_result.scalars().all() branches_list = [ BranchBrief( id=str(b.id), name=b.name, city=b.city, is_active=(b.status == "active") if b.status else True, ) for b in branch_rows ] # 3. Tagok lekĂ©rĂ©se eager loading-gal (User + Person) members_stmt = ( select(OrganizationMember) .options( selectinload(OrganizationMember.user).selectinload(User.person), selectinload(OrganizationMember.person), ) .where( OrganizationMember.organization_id == org_id, ) .order_by(OrganizationMember.id) ) members_result = await db.execute(members_stmt) member_rows = members_result.scalars().all() # TaglĂ©tszĂĄm (aktĂ­v tagok szĂĄma) member_count = sum(1 for m in member_rows if m.status == "active") # 4. ElsƑdleges kapcsolattartĂł lekĂ©rĂ©se contact_stmt = ( select(ContactPerson) .options(selectinload(ContactPerson.person)) .where( ContactPerson.organization_id == org_id, ContactPerson.is_primary == True, ) .limit(1) ) contact_result = await db.execute(contact_stmt) contact = contact_result.scalar_one_or_none() # 5. Subscription összefoglalĂł — P0 ARCHITECTURE UNIFICATION # Unified limit extraction via _extract_limit() helper. # Multi-subscription aggregation: Base + Add-on tiers. # Path A and Path B are now ONE unified code path. subscription_summary = None if active_subs: # ── P0 ARCHITECTURE UNIFICATION: Aggregate across ALL active subscriptions ── # Separate Base subscriptions from Add-on subscriptions base_subs = [s for s in active_subs if s.tier and s.tier.type in (None, '', 'base')] addon_subs = [s for s in active_subs if s.tier and s.tier.type == 'addon'] # Use the highest-tier Base subscription for tier metadata # (sort by tier_level descending, pick the first) base_subs_sorted = sorted( base_subs, key=lambda s: s.tier.tier_level if s.tier else 0, reverse=True, ) # P0 CRITICAL FIX: primary_sub MUST be a base tier, NEVER an add-on. # The fallback active_subs[0] could pick an add-on row if no base subs exist. primary_sub = base_subs_sorted[0] if base_subs_sorted else None if primary_sub and primary_sub.tier: # ── Base limits from primary subscription (using _extract_limit) ── primary_rules = primary_sub.tier.rules or {} primary_fc = primary_sub.tier.feature_capabilities or {} # vehicle_limit_keys: search in rules, then allowances, then feature_capabilities base_vehicles = _extract_limit( primary_rules, ["max_vehicles", "max_assets"], _extract_limit(primary_fc, ["max_vehicles", "max_assets"], 1), ) base_branches = _extract_limit( primary_rules, ["max_branches", "max_garages"], _extract_limit(primary_fc, ["max_branches", "max_garages"], 0), ) base_users = _extract_limit( primary_rules, ["max_users", "max_members"], _extract_limit(primary_fc, ["max_users", "max_members"], 1), ) # ── Aggregate extra_allowances from ALL active subscriptions ── total_extra_vehicles = 0 total_extra_branches = 0 total_extra_users = 0 aggregated_extra = {} for sub in active_subs: extra = sub.extra_allowances or {} total_extra_vehicles += extra.get("extra_vehicles", 0) or 0 total_extra_branches += extra.get("extra_branches", 0) or 0 total_extra_users += extra.get("extra_users", 0) or 0 # Merge extra_allowances dicts (later subs overwrite earlier ones for same keys) aggregated_extra.update(extra) # ── Also aggregate add-on tier base limits ── for addon_sub in addon_subs: if addon_sub.tier: addon_rules = addon_sub.tier.rules or {} addon_fc = addon_sub.tier.feature_capabilities or {} base_vehicles += _extract_limit( addon_rules, ["max_vehicles", "max_assets"], _extract_limit(addon_fc, ["max_vehicles", "max_assets"], 0), ) base_branches += _extract_limit( addon_rules, ["max_branches", "max_garages"], _extract_limit(addon_fc, ["max_branches", "max_garages"], 0), ) base_users += _extract_limit( addon_rules, ["max_users", "max_members"], _extract_limit(addon_fc, ["max_users", "max_members"], 0), ) # Total = Base (primary + addon) + Extra (from all subscriptions) asset_limit = base_vehicles + total_extra_vehicles branch_limit = base_branches + total_extra_branches user_limit = base_users + total_extra_users # P0 HOTFIX: Clamping — ensure asset_limit >= 1 even if all sums are 0 asset_limit = max(asset_limit, 1) # ── P0 ITEMIZED ADD-ON: Build add-on list ── addon_list = [] # 1. Add-ons from dedicated addon-type subscriptions (P0 itemized path) for addon_sub in addon_subs: extra = addon_sub.extra_allowances or {} for addon_type, qty in extra.items(): if qty: valid_until_str = addon_sub.valid_until.isoformat() if addon_sub.valid_until else None addon_list.append(AddonInfo( type=addon_type, quantity=qty, subscription_id=addon_sub.id, valid_until=valid_until_str, expires_at=valid_until_str, tier_name=addon_sub.tier.name if addon_sub.tier else None, )) # 2. Legacy add-ons from base subscription JSONB extra_allowances # (migration path: old data stored directly on base sub) existing_addon_types = {a.type for a in addon_list} for base_sub in base_subs: legacy_extra = base_sub.extra_allowances or {} for addon_type, qty in legacy_extra.items(): if qty and addon_type not in existing_addon_types: valid_until_str = base_sub.valid_until.isoformat() if base_sub.valid_until else None addon_list.append(AddonInfo( type=addon_type, quantity=qty, subscription_id=base_sub.id, # Legacy add-ons have no explicit valid_until; # fall back to base subscription's expires_at valid_until=valid_until_str, expires_at=valid_until_str, tier_name=None, )) existing_addon_types.add(addon_type) subscription_summary = SubscriptionSummary( tier_name=primary_sub.tier.name, tier_level=primary_sub.tier.tier_level, tier_id=primary_sub.tier.id, valid_from=primary_sub.valid_from.isoformat() if primary_sub.valid_from else None, expires_at=primary_sub.valid_until.isoformat() if primary_sub.valid_until else None, is_active=primary_sub.is_active, asset_count=asset_count, asset_limit=asset_limit, branch_limit=branch_limit, user_limit=user_limit, extra_allowances=aggregated_extra, addons=addon_list, ) elif org.subscription_tier: # Fallback: Organization.subscription_tier_id kapcsolat (nincs OrganizationSubscription) rules = org.subscription_tier.rules or {} fc = org.subscription_tier.feature_capabilities or {} base_vehicles = _extract_limit( rules, ["max_vehicles", "max_assets"], _extract_limit(fc, ["max_vehicles", "max_assets"], 1), ) base_branches = _extract_limit( rules, ["max_branches", "max_garages"], _extract_limit(fc, ["max_branches", "max_garages"], 0), ) base_users = _extract_limit( rules, ["max_users", "max_members"], _extract_limit(fc, ["max_users", "max_members"], 1), ) # P0 HOTFIX: Clamping — ensure asset_limit >= 1 even for fallback path base_vehicles = max(base_vehicles, 1) subscription_summary = SubscriptionSummary( tier_name=org.subscription_tier.name, tier_level=org.subscription_tier.tier_level, expires_at=org.subscription_expires_at.isoformat() if org.subscription_expires_at else None, asset_count=asset_count, asset_limit=base_vehicles, branch_limit=base_branches, user_limit=base_users, ) # ── P0 AUDIT TRAIL: Fetch subscription history from audit.audit_logs ── subscription_history: List[SubscriptionHistoryItem] = [] try: audit_stmt = ( select(AuditLog) .where( AuditLog.target_type == "organization", AuditLog.target_id == str(org_id), AuditLog.action.in_(["SUBSCRIPTION_TIER_CHANGE", "SUBSCRIPTION_ADDON_OVERRIDE"]), ) .order_by(AuditLog.timestamp.desc()) .limit(50) ) audit_result = await db.execute(audit_stmt) audit_rows = audit_result.scalars().all() for log_entry in audit_rows: # Build a human-readable changes_summary changes_summary = None tier_name = None if log_entry.action == "SUBSCRIPTION_TIER_CHANGE": old_tier = (log_entry.old_data or {}).get("tier_id") new_tier_data = (log_entry.new_data or {}) new_tier = new_tier_data.get("tier_id") new_tier_name = new_tier_data.get("tier_name", f"Tier #{new_tier}") tier_name = new_tier_name changes_summary = f"Csomag vĂĄltĂĄs: Tier #{old_tier} → {new_tier_name}" elif log_entry.action == "SUBSCRIPTION_ADDON_OVERRIDE": old_addons = (log_entry.old_data or {}).get("addons", {}) new_addons = (log_entry.new_data or {}).get("addons", {}) changes_summary = f"KiegĂ©szĂ­tƑk mĂłdosĂ­tĂĄsa" tier_name = "KiegĂ©szĂ­tƑk" subscription_history.append(SubscriptionHistoryItem( id=log_entry.id, action=log_entry.action, admin_user_id=log_entry.user_id, old_data=log_entry.old_data, new_data=log_entry.new_data, timestamp=log_entry.timestamp.isoformat() if log_entry.timestamp else None, tier_name=tier_name, changes_summary=changes_summary, )) except Exception as e: logger.warning(f"P0 AUDIT TRAIL: Failed to fetch subscription history for org {org_id}: {e}") # 6. KapcsolattartĂł adatok primary_contact = None if contact and contact.person: primary_contact = ContactPersonInfo( id=contact.id, full_name=f"{contact.person.last_name} {contact.person.first_name}", role=contact.role, department=contact.department, phone=contact.person.phone, is_primary=contact.is_primary, ) # ───────────────────────────────────────────────────────────────────── # P0: Owner adatok betöltĂ©se a Zero-Duplication Contact Logic-hoz # ───────────────────────────────────────────────────────────────────── owner: Optional[User] = org.owner owner_person: Optional[Person] = None owner_full_name: Optional[str] = None owner_email: Optional[str] = None owner_phone: Optional[str] = None if owner: owner_email = owner.email owner_person = owner.person if owner_person: owner_full_name = f"{owner_person.last_name} {owner_person.first_name}".strip() owner_phone = owner_person.phone # ───────────────────────────────────────────────────────────────────── # P0: Zero-Duplication Override Pattern # Ha a contact mezƑ NULL, az owner adatait injectĂĄljuk # ───────────────────────────────────────────────────────────────────── resolved_contact_person_name = org.contact_person_name or owner_full_name resolved_contact_email = org.contact_email or owner_email resolved_contact_phone = org.contact_phone or owner_phone # 7. Tagok összeĂĄllĂ­tĂĄsa nested Person adatokkal members_list: List[OrganizationMemberResponse] = [] owner_found_in_members = False for m in member_rows: person_data = None # Try to get Person from the member's direct person relationship first person_obj = m.person # Fallback: get Person through the User relationship if not person_obj and m.user and m.user.person: person_obj = m.user.person if person_obj: # Get email from the user record email = m.user.email if m.user else None person_data = PersonBrief( id=person_obj.id, first_name=person_obj.first_name or "", last_name=person_obj.last_name or "", email=email, phone=person_obj.phone, ) # Check if this member is the owner if owner and m.user_id == owner.id and m.role == "OWNER": owner_found_in_members = True members_list.append(OrganizationMemberResponse( id=m.id, user_id=m.user_id, organization_id=m.organization_id, role=m.role, status=m.status or "active", is_verified=m.is_verified, person=person_data, created_at=m.created_at.isoformat() if m.created_at else None, updated_at=m.updated_at.isoformat() if m.updated_at else None, )) # ───────────────────────────────────────────────────────────────────── # P0: Ha az owner NEM szerepel a members listĂĄban, dinamikusan hozzĂĄfƱzzĂŒk # ───────────────────────────────────────────────────────────────────── if owner and not owner_found_in_members: # Build a fully-formed PersonBrief — even if owner_person is None, # we still provide an empty PersonBrief so the frontend never crashes # on null person access. owner_person_data = PersonBrief( id=owner_person.id if owner_person else None, first_name=owner_person.first_name or "" if owner_person else "", last_name=owner_person.last_name or "" if owner_person else "", email=owner_email, phone=owner_phone, ) # Create a synthetic member entry for the owner. # P0 HOTFIX: The synthetic member MUST include display_name/name/email # at the top level so the frontend memberFullName() helper can render # a human-readable name instead of falling back to user_id. synthetic_owner = OrganizationMemberResponse( id=0, # synthetic marker user_id=owner.id, organization_id=org_id, role="OWNER", status="active", is_verified=True, person=owner_person_data, created_at=org.created_at.isoformat() if org.created_at else None, updated_at=None, ) # Inject top-level display fields for frontend memberFullName() # (the Pydantic model doesn't have these fields, so we use object.__setattr__) object.__setattr__(synthetic_owner, 'display_name', owner_full_name or owner_email or f"Owner #{owner.id}") object.__setattr__(synthetic_owner, 'name', owner_full_name or owner_email or f"Owner #{owner.id}") object.__setattr__(synthetic_owner, 'email', owner_email) # Prepend the owner so they appear first in the list members_list.insert(0, synthetic_owner) # Also bump the member_count member_count += 1 logger.info( f"P0: Dynamically prepended owner user_id={owner.id} to members list " f"for org {org_id} ({org.full_name})" ) logger.info( f"Admin {current_user.id} ({current_user.email}) fetched details for org {org_id} " f"({org.full_name}) — {len(members_list)} members" ) return GarageDetailsResponse( id=org.id, name=org.name, full_name=org.full_name, display_name=org.display_name, status=org.status, is_active=org.is_active, is_verified=org.is_verified, org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type), address_city=org.address_city, address_zip=org.address_zip, address_street_name=org.address_street_name, address_street_type=org.address_street_type, address_house_number=org.address_house_number, tax_number=org.tax_number, reg_number=org.reg_number, # ── P0: Zero-Duplication KapcsolattartĂł (owner fallback) ── contact_person_name=resolved_contact_person_name, contact_email=resolved_contact_email, contact_phone=resolved_contact_phone, # ── SzĂĄmlĂĄzĂĄsi cĂ­m ── billing_zip=org.billing_zip, billing_city=org.billing_city, billing_street_name=org.billing_street_name, billing_street_type=org.billing_street_type, billing_house_number=org.billing_house_number, # ── ÉrtesĂ­tĂ©si cĂ­m ── notification_zip=org.notification_zip, notification_city=org.notification_city, notification_street_name=org.notification_street_name, notification_street_type=org.notification_street_type, notification_house_number=org.notification_house_number, subscription=subscription_summary, primary_contact=primary_contact, created_at=org.created_at.isoformat() if org.created_at else None, member_count=member_count, # P0: Expose owner user ID and person ID for frontend display owner_user_id=owner.id if owner else None, owner_person_id=owner_person.id if owner_person else None, members=members_list, # ── P0 ADD-ON UPGRADE: Branches list ── branches=branches_list, # ── P0 AUDIT TRAIL: Subscription history ── subscription_history=subscription_history, ) # ============================================================================= # PUT /{org_id}/status — GarĂĄzs stĂĄtuszĂĄnak mĂłdosĂ­tĂĄsa # ============================================================================= class OrgStatusUpdate(BaseModel): """StĂĄtusz mĂłdosĂ­tĂĄsĂĄnak kĂ©rĂ©se.""" status: str = Field(..., description="Új stĂĄtusz: active, inactive, suspended, pending_verification") @router.put( "/{org_id}/status", summary="GarĂĄzs stĂĄtuszĂĄnak mĂłdosĂ­tĂĄsa", description="FrissĂ­ti egy garĂĄzs/szervezet stĂĄtuszĂĄt. " "LehetsĂ©ges Ă©rtĂ©kek: active, inactive, suspended, pending_verification. " "A stĂĄtuszvĂĄltĂĄs hatĂĄssal van az is_active mezƑre is.", ) async def update_org_status( org_id: int, payload: OrgStatusUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:edit")), ) -> Dict[str, Any]: """ GarĂĄzs stĂĄtuszĂĄnak mĂłdosĂ­tĂĄsa. - EllenƑrzi, hogy a szervezet lĂ©tezik-e. - FrissĂ­ti a status mezƑt a megadott Ă©rtĂ©kre. - Az is_active mezƑt automatikusan szinkronizĂĄlja: - 'active' → is_active = True - 'inactive' vagy 'suspended' → is_active = False """ # 1. EllenƑrizzĂŒk a szervezetet org_stmt = select(Organization).where(Organization.id == org_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem talĂĄlhatĂł ID-vel: {org_id}", ) # 2. ValidĂĄljuk a stĂĄtusz Ă©rtĂ©ket valid_statuses = {"active", "inactive", "suspended", "pending_verification"} new_status = payload.status.lower() if new_status not in valid_statuses: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"ÉrvĂ©nytelen stĂĄtusz: '{payload.status}'. " f"LehetsĂ©ges Ă©rtĂ©kek: {', '.join(sorted(valid_statuses))}", ) # 3. MentjĂŒk a rĂ©gi stĂĄtuszt naplĂłzĂĄshoz old_status = org.status # 4. FrissĂ­tjĂŒk a stĂĄtuszt Ă©s az is_active mezƑt org.status = new_status org.is_active = (new_status == "active") # Ha inaktivĂĄljuk, jegyezzĂŒk fel az idƑpontot if new_status in ("inactive", "suspended") and old_status == "active": from datetime import datetime as dt org.last_deactivated_at = dt.utcnow() await db.commit() await db.refresh(org) logger.info( f"Admin {current_user.id} ({current_user.email}) changed status for org {org_id} " f"({org.full_name}): '{old_status}' → '{new_status}'" ) return { "status": "success", "message": f"A(z) '{org.full_name}' garĂĄzs stĂĄtusza frissĂ­tve: '{old_status}' → '{new_status}'.", "org_id": org.id, "old_status": old_status, "new_status": new_status, "is_active": org.is_active, } # ============================================================================= # PUT /{org_id} — GarĂĄzs adatainak szerkesztĂ©se # ============================================================================= class OrganizationUpdate(BaseModel): """GarĂĄzs adatainak szerkesztĂ©sĂ©re szolgĂĄlĂł sĂ©ma (kibƑvĂ­tett CRM).""" name: Optional[str] = Field(None, min_length=1, max_length=255, description="Rövid nĂ©v (slug-szerƱ)") full_name: Optional[str] = Field(None, min_length=1, max_length=255, description="Teljes cĂ©gnĂ©v") display_name: Optional[str] = Field(None, max_length=50, description="MegjelenĂ­tĂ©si nĂ©v") tax_number: Optional[str] = Field(None, max_length=20, description="AdĂłszĂĄm") reg_number: Optional[str] = Field(None, max_length=50, description="CĂ©gjegyzĂ©kszĂĄm") # ── Org-level email/phone (used for individual garages to propagate to Person/User) ── email: Optional[str] = Field(None, max_length=255, description="Szervezeti e-mail (individual garages esetĂ©n a User rekordba propagĂĄlĂłdik)") phone: Optional[str] = Field(None, max_length=50, description="Szervezeti telefon (individual garages esetĂ©n a Person rekordba propagĂĄlĂłdik)") # ── ElsƑdleges cĂ­m (Primary Address) ── address_zip: Optional[str] = Field(None, max_length=10, description="IrĂĄnyĂ­tĂłszĂĄm") address_city: Optional[str] = Field(None, max_length=100, description="VĂĄros") address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve") address_street_type: Optional[str] = Field(None, max_length=50, description="Utca tĂ­pusa (utca, tĂ©r, stb.)") address_house_number: Optional[str] = Field(None, max_length=20, description="HĂĄzszĂĄm") # ── KapcsolattartĂł (Contact Person) ── contact_person_name: Optional[str] = Field(None, max_length=255, description="KapcsolattartĂł neve") contact_email: Optional[str] = Field(None, max_length=255, description="KapcsolattartĂł e-mail cĂ­me") contact_phone: Optional[str] = Field(None, max_length=50, description="KapcsolattartĂł telefonszĂĄma") # ── SzĂĄmlĂĄzĂĄsi cĂ­m (Billing Address) ── billing_zip: Optional[str] = Field(None, max_length=10, description="SzĂĄmlĂĄzĂĄsi irĂĄnyĂ­tĂłszĂĄm") billing_city: Optional[str] = Field(None, max_length=100, description="SzĂĄmlĂĄzĂĄsi vĂĄros") billing_street_name: Optional[str] = Field(None, max_length=150, description="SzĂĄmlĂĄzĂĄsi utca neve") billing_street_type: Optional[str] = Field(None, max_length=50, description="SzĂĄmlĂĄzĂĄsi utca tĂ­pusa") billing_house_number: Optional[str] = Field(None, max_length=20, description="SzĂĄmlĂĄzĂĄsi hĂĄzszĂĄm") # ── ÉrtesĂ­tĂ©si cĂ­m (Notification Address) ── notification_zip: Optional[str] = Field(None, max_length=10, description="ÉrtesĂ­tĂ©si irĂĄnyĂ­tĂłszĂĄm") notification_city: Optional[str] = Field(None, max_length=100, description="ÉrtesĂ­tĂ©si vĂĄros") notification_street_name: Optional[str] = Field(None, max_length=150, description="ÉrtesĂ­tĂ©si utca neve") notification_street_type: Optional[str] = Field(None, max_length=50, description="ÉrtesĂ­tĂ©si utca tĂ­pusa") notification_house_number: Optional[str] = Field(None, max_length=20, description="ÉrtesĂ­tĂ©si hĂĄzszĂĄm") @router.put( "/{org_id}", summary="GarĂĄzs adatainak szerkesztĂ©se", description="FrissĂ­ti egy garĂĄzs alapadatait (nĂ©v, adĂłszĂĄm, cĂ­m, stb.). " "Csak a megadott mezƑk frissĂŒlnek; a None Ă©rtĂ©kƱ mezƑk vĂĄltozatlanok maradnak.", ) async def update_organization( org_id: int, payload: OrganizationUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:edit")), ) -> Dict[str, Any]: """ GarĂĄzs adatainak szerkesztĂ©se. - EllenƑrzi, hogy a szervezet lĂ©tezik-e. - Csak a megadott (nem None) mezƑket frissĂ­ti. - Visszaadja a frissĂ­tett adatokat. """ # 1. EllenƑrizzĂŒk a szervezetet org_stmt = select(Organization).where(Organization.id == org_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem talĂĄlhatĂł ID-vel: {org_id}", ) # 2. ÖsszegyƱjtjĂŒk a vĂĄltozĂĄsokat update_fields = {} for field_name in payload.model_dump(exclude_none=True): value = getattr(payload, field_name) if value is not None and hasattr(org, field_name): setattr(org, field_name, value) update_fields[field_name] = value if not update_fields: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Nem adtĂĄl meg egyetlen mĂłdosĂ­tandĂł mezƑt sem.", ) # 3. Individual (private) garages: propagate contact info to Person/User if org.org_type == 'individual' and org.legal_owner_id: person_stmt = select(Person).where(Person.id == org.legal_owner_id) person_result = await db.execute(person_stmt) person = person_result.scalar_one_or_none() if person: person_updated = False # Propagate phone to Person record (from either contact_phone or phone field) phone_value = update_fields.get('contact_phone') or update_fields.get('phone') if phone_value: person.phone = phone_value person_updated = True # Propagate email to the linked User record (from either contact_email or email field) email_value = update_fields.get('contact_email') or update_fields.get('email') if email_value and person.user_id: user_stmt = select(User).where(User.id == person.user_id) user_result = await db.execute(user_stmt) user = user_result.scalar_one_or_none() if user: user.email = email_value logger.info( f"Propagated email to user {user.id} for individual org {org_id}" ) if person_updated: logger.info( f"Propagated phone to person {person.id} for individual org {org_id}" ) await db.commit() await db.refresh(org) logger.info( f"Admin {current_user.id} ({current_user.email}) updated org {org_id} " f"({org.full_name}): fields={list(update_fields.keys())}" ) return { "status": "success", "message": f"A(z) '{org.full_name}' garĂĄzs adatai frissĂ­tve.", "org_id": org.id, "updated_fields": list(update_fields.keys()), "organization": { "id": org.id, "name": org.name, "full_name": org.full_name, "display_name": org.display_name, "tax_number": org.tax_number, "reg_number": org.reg_number, "address_zip": org.address_zip, "address_city": org.address_city, "address_street_name": org.address_street_name, "address_street_type": org.address_street_type, "address_house_number": org.address_house_number, "status": org.status, "is_active": org.is_active, "contact_email": org.contact_email, "contact_phone": org.contact_phone, "contact_person_name": org.contact_person_name, }, } # ============================================================================= # PATCH /{org_id}/subscription — ElƑfizetĂ©s mĂłdosĂ­tĂĄsa # ============================================================================= @router.patch( "/{org_id}/subscription", summary="GarĂĄzs elƑfizetĂ©sĂ©nek mĂłdosĂ­tĂĄsa", description="FrissĂ­ti vagy lĂ©trehozza egy garĂĄzs OrganizationSubscription rekordjĂĄt. " "LehetƑsĂ©g van egyedi lejĂĄrati dĂĄtum megadĂĄsĂĄra (pl. 2-5 Ă©ves B2B deal). " "P0 ITEMIZED ADD-ON: Minden add-on tĂ­pus kĂŒlön sorban tĂĄrolva.", ) async def update_org_subscription( org_id: int, payload: OrgSubscriptionUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:edit")), ) -> Dict[str, Any]: """ GarĂĄzs elƑfizetĂ©sĂ©nek mĂłdosĂ­tĂĄsa. KĂ©t ĂŒzemmĂłd: Mode A (tier_id megadva — Teljes elƑfizetĂ©s vĂĄltĂĄs): - EllenƑrzi, hogy a SubscriptionTier lĂ©tezik-e. - InaktivĂĄlja a meglĂ©vƑ aktĂ­v OrganizationSubscription sorokat. - LĂ©trehoz egy Ășj Base OrganizationSubscription rekordot a megadott tier_id-val. - LĂ©trehoz kĂŒlön add-on sorokat minden pozitĂ­v extra_* Ă©rtĂ©khez. Mode B (tier_id = None — Csak add-on frissĂ­tĂ©s): - P0 ITEMIZED ADD-ON: Minden add-on tĂ­pushoz kĂŒlön OrganizationSubscription sort hoz lĂ©tre vagy frissĂ­t, ahelyett hogy egyetlen JSONB-t Ă­rna felĂŒl. - Ha egy add-on Ă©rtĂ©ke 0, a meglĂ©vƑ sor inaktivĂĄlĂłdik. - Ha egy add-on Ă©rtĂ©ke > 0, lĂ©trejön/aktivĂĄlĂłdik egy dedikĂĄlt sor. """ # ── P0 AUDIT TRAIL: Capture previous subscription state before modification ── previous_tier_id = None previous_addons = {} prev_sub_stmt = ( select(OrganizationSubscription) .options(selectinload(OrganizationSubscription.tier)) .where( OrganizationSubscription.org_id == org_id, OrganizationSubscription.is_active == True, ) .order_by(OrganizationSubscription.id.desc()) .limit(1) ) prev_sub_result = await db.execute(prev_sub_stmt) prev_sub = prev_sub_result.scalar_one_or_none() if prev_sub: previous_tier_id = prev_sub.tier_id previous_addons = prev_sub.extra_allowances or {} # 1. EllenƑrizzĂŒk a szervezetet org_stmt = select(Organization).where(Organization.id == org_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem talĂĄlhatĂł ID-vel: {org_id}", ) now = datetime.utcnow() # ── P0 ITEMIZED ADD-ON: Inner helper to upsert a single add-on row ── async def _upsert_addon_sub(addon_type: str, quantity: int, expires: Optional[datetime]) -> Optional[int]: """Create or deactivate a single add-on subscription row. Returns the ID of the created/updated row, or None if deactivated/skipped. P0 CRITICAL SAFETY: When quantity <= 0, we ONLY deactivate existing rows. We NEVER create new rows with zero quantity. This prevents the frontend from accidentally creating zombie add-on rows when the form initializes add-on values to 0 instead of reading from existing subscription data. """ if quantity <= 0: # Deactivate any existing active add-on row for this type # P0 CRITICAL FIX: Use JSONB .astext.is_not(None) to reliably find rows # where this addon_type key exists in extra_allowances. # The previous approach using [addon_type].as_integer().is_not(None) could # fail if the JSONB structure doesn't match the expected path. existing_stmt = ( select(OrganizationSubscription) .where( OrganizationSubscription.org_id == org_id, OrganizationSubscription.is_active == True, # Use JSONB containment: check if extra_allowances contains the key OrganizationSubscription.extra_allowances[addon_type].astext.is_not(None), ) .order_by(OrganizationSubscription.id.desc()) .limit(1) ) existing_result = await db.execute(existing_stmt) existing = existing_result.scalar_one_or_none() if existing: existing.is_active = False logger.info( f"P0 ITEMIZED: Deactivated add-on row {existing.id} " f"for {addon_type} (org {org_id})" ) else: logger.info( f"P0 ITEMIZED: No active add-on row found for {addon_type} " f"(org {org_id}) — skipping deactivation" ) return None # quantity > 0: find or create the add-on tier addon_tier_id = await _find_or_create_addon_tier(db, addon_type) if addon_tier_id is None: logger.warning(f"P0 ITEMIZED: Could not find/create add-on tier for {addon_type}") return None # Normalize expiration addon_valid_until = normalize_to_end_of_day(expires) if expires else None # Check if there's already an active add-on row for this type existing_stmt = ( select(OrganizationSubscription) .where( OrganizationSubscription.org_id == org_id, OrganizationSubscription.is_active == True, OrganizationSubscription.tier_id == addon_tier_id, ) .order_by(OrganizationSubscription.id.desc()) .limit(1) ) existing_result = await db.execute(existing_stmt) existing = existing_result.scalar_one_or_none() if existing: # Update existing row existing.extra_allowances = {addon_type: quantity} if addon_valid_until: existing.valid_until = addon_valid_until logger.info( f"P0 ITEMIZED: Updated add-on row {existing.id} " f"for {addon_type}={quantity} (org {org_id})" ) return existing.id else: # Create new add-on row new_addon = OrganizationSubscription( org_id=org_id, tier_id=addon_tier_id, valid_from=now, valid_until=addon_valid_until, is_active=True, extra_allowances={addon_type: quantity}, ) db.add(new_addon) await db.flush() logger.info( f"P0 ITEMIZED: Created add-on row for {addon_type}={quantity} (org {org_id})" ) return new_addon.id # ── Mode B: tier_id is None — Only add-on update ── if payload.tier_id is None: # P0 ITEMIZED ADD-ON: Individual expiration dates per add-on type addon_expires_map = { "extra_vehicles": normalize_to_end_of_day(payload.extra_vehicles_expires_at) if payload.extra_vehicles_expires_at else None, "extra_branches": normalize_to_end_of_day(payload.extra_branches_expires_at) if payload.extra_branches_expires_at else None, "extra_users": normalize_to_end_of_day(payload.extra_users_expires_at) if payload.extra_users_expires_at else None, } # Upsert each add-on type addon_results = {} addon_ids = [] for addon_type, quantity in [ ("extra_vehicles", payload.extra_vehicles), ("extra_branches", payload.extra_branches), ("extra_users", payload.extra_users), ]: row_id = await _upsert_addon_sub(addon_type, quantity, addon_expires_map[addon_type]) addon_results[addon_type] = row_id if row_id: addon_ids.append(row_id) # ── P0 MIGRATION: Clear legacy extra_allowances on the base subscription ── # When Mode B creates itemized add-on rows, we MUST clear the legacy # extra_allowances on the base subscription to prevent double-counting. # Set to 0 for the add-on types that were explicitly provided in the payload. base_sub_stmt = ( select(OrganizationSubscription) .where( OrganizationSubscription.org_id == org_id, OrganizationSubscription.is_active == True, OrganizationSubscription.tier_id.isnot(None), # Base subscription has a real tier_id ) .order_by(OrganizationSubscription.id.desc()) .limit(1) ) base_sub_result = await db.execute(base_sub_stmt) base_sub = base_sub_result.scalar_one_or_none() if base_sub and base_sub.extra_allowances: legacy_cleanup = {} for addon_key in ["extra_vehicles", "extra_branches", "extra_users"]: if getattr(payload, addon_key, 0) > 0: legacy_cleanup[addon_key] = 0 if legacy_cleanup: merged = dict(base_sub.extra_allowances) merged.update(legacy_cleanup) base_sub.extra_allowances = merged logger.info( f"P0 MIGRATION: Cleared legacy extra_allowances on base sub {base_sub.id} " f"for org {org_id}: {legacy_cleanup}" ) # ── P0 AUDIT TRAIL: Log add-on override to audit.audit_logs ── new_addons_data = { "extra_vehicles": payload.extra_vehicles, "extra_branches": payload.extra_branches, "extra_users": payload.extra_users, } audit_entry = AuditLog( user_id=current_user.id, severity=LogSeverity.info, action="SUBSCRIPTION_ADDON_OVERRIDE", target_type="organization", target_id=str(org_id), old_data={"addons": previous_addons}, new_data={"addons": new_addons_data}, ) db.add(audit_entry) await db.commit() # Build response with itemized add-on list addon_list = [] for addon_type, row_id in addon_results.items(): addon_list.append({ "type": addon_type, "quantity": getattr(payload, addon_type), "subscription_id": row_id, }) logger.info( f"Admin {current_user.id} ({current_user.email}) updated add-ons for org {org_id}: " f"extra_vehicles={payload.extra_vehicles}, " f"extra_branches={payload.extra_branches}, " f"extra_users={payload.extra_users} " f"(Mode B, ITEMIZED)" ) return { "status": "success", "message": f"Add-on-ok frissĂ­tve a '{org.full_name}' garĂĄzs szĂĄmĂĄra.", "addons": addon_list, } # ── Mode A: tier_id is provided — Full subscription change ── # 2. EllenƑrizzĂŒk a SubscriptionTier-t tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == payload.tier_id) tier_result = await db.execute(tier_stmt) tier = tier_result.scalar_one_or_none() if not tier: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"ElƑfizetĂ©si csomag nem talĂĄlhatĂł ID-vel: {payload.tier_id}", ) # 3. InaktivĂĄljuk a meglĂ©vƑ aktĂ­v subscription-öket await db.execute( sa_update(OrganizationSubscription) .where( OrganizationSubscription.org_id == org_id, OrganizationSubscription.is_active == True, ) .values(is_active=False) ) # 4. SzĂĄmoljuk a lejĂĄrati dĂĄtumot if payload.expires_at: valid_until = payload.expires_at else: # Ha nincs megadva, a csomag duration.days alapjĂĄn szĂĄmolunk rules = tier.rules or {} duration = rules.get("duration", {}) days = duration.get("days", 30) if isinstance(duration, dict) else 30 from datetime import timedelta valid_until = now + timedelta(days=days) # Normalize base subscription expiration valid_until = normalize_to_end_of_day(valid_until) # 5. LĂ©trehozzuk az Ășj Base subscription rekordot (extra_allowances ĂŒresen) new_sub = OrganizationSubscription( org_id=org_id, tier_id=payload.tier_id, valid_from=now, valid_until=valid_until, is_active=True, extra_allowances={}, # Base tier has no extra allowances ) db.add(new_sub) await db.flush() # 6. P0 ITEMIZED ADD-ON: Create separate add-on rows for each add-on type # Each add-on type gets its OWN expiration date from the payload. # Falls back to the base subscription expires_at if individual date is not set. addon_list = [] addon_type_config = [ ("extra_vehicles", payload.extra_vehicles, payload.extra_vehicles_expires_at), ("extra_branches", payload.extra_branches, payload.extra_branches_expires_at), ("extra_users", payload.extra_users, payload.extra_users_expires_at), ] for addon_type, quantity, individual_expires in addon_type_config: # Use individual date if provided, otherwise fall back to base subscription expires_at addon_expires = normalize_to_end_of_day(individual_expires or payload.expires_at) row_id = await _upsert_addon_sub(addon_type, quantity, addon_expires) if row_id: addon_list.append({ "type": addon_type, "quantity": quantity, "subscription_id": row_id, }) # 7. FrissĂ­tjĂŒk a Organization denormalizĂĄlt mezƑit is org.subscription_tier_id = payload.tier_id org.subscription_expires_at = valid_until org.subscription_plan = tier.name # ── P0 AUDIT TRAIL: Log full subscription tier change to audit.audit_logs ── new_tier_data = { "tier_id": payload.tier_id, "tier_name": tier.name, "valid_until": valid_until.isoformat() if valid_until else None, "addons": { "extra_vehicles": payload.extra_vehicles, "extra_branches": payload.extra_branches, "extra_users": payload.extra_users, }, } audit_entry = AuditLog( user_id=current_user.id, severity=LogSeverity.info, action="SUBSCRIPTION_TIER_CHANGE", target_type="organization", target_id=str(org_id), old_data={ "tier_id": previous_tier_id, "addons": previous_addons, }, new_data=new_tier_data, ) db.add(audit_entry) await db.commit() await db.refresh(new_sub) logger.info( f"Admin {current_user.id} ({current_user.email}) updated subscription for org {org_id}: " f"tier_id={payload.tier_id}, tier_name={tier.name}, " f"valid_until={valid_until.isoformat()}, " f"addons={addon_list}" ) return { "status": "success", "message": f"ElƑfizetĂ©s frissĂ­tve a '{org.full_name}' garĂĄzs szĂĄmĂĄra.", "subscription": { "id": new_sub.id, "org_id": new_sub.org_id, "tier_id": new_sub.tier_id, "tier_name": tier.name, "valid_from": new_sub.valid_from.isoformat() if new_sub.valid_from else None, "valid_until": new_sub.valid_until.isoformat() if new_sub.valid_until else None, "is_active": new_sub.is_active, "extra_allowances": {}, }, "addons": addon_list, } # ============================================================================= # POST /{org_id}/members — Új tag hozzĂĄadĂĄsa a garĂĄzshoz # ============================================================================= @router.post( "/{org_id}/members", response_model=OrganizationMemberResponse, status_code=status.HTTP_201_CREATED, summary="Új tag hozzĂĄadĂĄsa", description="HozzĂĄad egy felhasznĂĄlĂłt a garĂĄzshoz a megadott szerepkörrel. " "TĂĄmogatja a user_id Ă©s email alapĂș hozzĂĄadĂĄst is.", ) async def add_organization_member( org_id: int, payload: OrganizationMemberCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:manage-members")), ) -> OrganizationMemberResponse: """ Új tag hozzĂĄadĂĄsa egy garĂĄzshoz. KĂ©t mĂłd tĂĄmogatott: 1. user_id alapjĂĄn (közvetlen) 2. email alapjĂĄn (a rendszer megkeresi a felhasznĂĄlĂłt) - EllenƑrzi, hogy a szervezet lĂ©tezik-e. - EllenƑrzi, hogy a felhasznĂĄlĂł lĂ©tezik-e az identity.users tĂĄblĂĄban. - EllenƑrzi, hogy a felhasznĂĄlĂł mĂ©g nem tagja a szervezetnek. - LĂ©trehozza az OrganizationMember rekordot. """ # 1. EllenƑrizzĂŒk a szervezetet org_stmt = select(Organization).where(Organization.id == org_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem talĂĄlhatĂł ID-vel: {org_id}", ) # 2. FelhasznĂĄlĂł azonosĂ­tĂĄsa: user_id vagy email alapjĂĄn resolved_user_id = payload.user_id if resolved_user_id is None and payload.email: # Email-based lookup user_stmt = select(User).where(User.email == payload.email) user_result = await db.execute(user_stmt) user = user_result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"FelhasznĂĄlĂł nem talĂĄlhatĂł e-mail cĂ­mmel: {payload.email}", ) resolved_user_id = user.id elif resolved_user_id is not None: # user_id-based lookup user_stmt = select(User).where(User.id == resolved_user_id) user_result = await db.execute(user_stmt) user = user_result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"FelhasznĂĄlĂł nem talĂĄlhatĂł ID-vel: {resolved_user_id}", ) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Meg kell adni a user_id vagy email mezƑt.", ) # 3. EllenƑrizzĂŒk, hogy a felhasznĂĄlĂł mĂ©g nem tagja a szervezetnek existing_stmt = select(OrganizationMember).where( OrganizationMember.organization_id == org_id, OrganizationMember.user_id == resolved_user_id, OrganizationMember.status != "archived", ) existing_result = await db.execute(existing_stmt) existing = existing_result.scalar_one_or_none() if existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"A felhasznĂĄlĂł (ID: {resolved_user_id}) mĂĄr tagja ennek a szervezetnek.", ) # 4. LĂ©trehozzuk az Ășj tag rekordot person_id = user.person_id new_member = OrganizationMember( organization_id=org_id, user_id=resolved_user_id, person_id=person_id, role=payload.role, status=payload.status, is_verified=False, ) db.add(new_member) await db.commit() await db.refresh(new_member) # 5. VisszatöltjĂŒk a kapcsolĂłdĂł adatokat a vĂĄlaszhoz await db.refresh(new_member, attribute_names=["user", "person"]) # Person adatok összeĂĄllĂ­tĂĄsa person_data = None person_obj = new_member.person if not person_obj and new_member.user and new_member.user.person: person_obj = new_member.user.person if person_obj: person_data = PersonBrief( first_name=person_obj.first_name or "", last_name=person_obj.last_name or "", email=user.email, phone=person_obj.phone, ) logger.info( f"Admin {current_user.id} ({current_user.email}) added member user_id={resolved_user_id} " f"to org {org_id} ({org.full_name}) with role={payload.role}" ) return OrganizationMemberResponse( id=new_member.id, user_id=new_member.user_id, organization_id=new_member.organization_id, role=new_member.role, status=new_member.status or "active", is_verified=new_member.is_verified, person=person_data, created_at=new_member.created_at.isoformat() if new_member.created_at else None, updated_at=new_member.updated_at.isoformat() if new_member.updated_at else None, ) # ============================================================================= # PUT /{org_id}/members/{member_id} — Tag adatainak mĂłdosĂ­tĂĄsa # ============================================================================= @router.put( "/{org_id}/members/{member_id}", response_model=OrganizationMemberResponse, summary="Tag adatainak mĂłdosĂ­tĂĄsa", description="FrissĂ­ti egy meglĂ©vƑ tag szerepkörĂ©t Ă©s/vagy stĂĄtuszĂĄt.", ) async def update_organization_member( org_id: int, member_id: int, payload: OrganizationMemberUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:manage-members")), ) -> OrganizationMemberResponse: """ MeglĂ©vƑ tag adatainak mĂłdosĂ­tĂĄsa. - EllenƑrzi, hogy a tag rekord lĂ©tezik Ă©s a megadott szervezethez tartozik. - FrissĂ­ti a role Ă©s/vagy status mezƑket. """ # 1. EllenƑrizzĂŒk a tag rekordot member_stmt = ( select(OrganizationMember) .options( selectinload(OrganizationMember.user).selectinload(User.person), selectinload(OrganizationMember.person), ) .where( OrganizationMember.id == member_id, OrganizationMember.organization_id == org_id, ) ) member_result = await db.execute(member_stmt) member = member_result.scalar_one_or_none() if not member: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tag nem talĂĄlhatĂł (ID: {member_id}) a szervezetben (ID: {org_id}).", ) # 2. FrissĂ­tjĂŒk a mezƑket if payload.role is not None: member.role = payload.role if payload.status is not None: member.status = payload.status await db.commit() await db.refresh(member) # 3. Person adatok összeĂĄllĂ­tĂĄsa person_data = None person_obj = member.person if not person_obj and member.user and member.user.person: person_obj = member.user.person if person_obj: email = member.user.email if member.user else None person_data = PersonBrief( first_name=person_obj.first_name or "", last_name=person_obj.last_name or "", email=email, phone=person_obj.phone, ) logger.info( f"Admin {current_user.id} ({current_user.email}) updated member {member_id} in org {org_id}: " f"role={payload.role}, status={payload.status}" ) return OrganizationMemberResponse( id=member.id, user_id=member.user_id, organization_id=member.organization_id, role=member.role, status=member.status or "active", is_verified=member.is_verified, person=person_data, created_at=member.created_at.isoformat() if member.created_at else None, updated_at=member.updated_at.isoformat() if member.updated_at else None, ) # ============================================================================= # DELETE /{org_id}/members/{member_id} — Tag eltĂĄvolĂ­tĂĄsa # ============================================================================= @router.delete( "/{org_id}/members/{member_id}", status_code=status.HTTP_200_OK, summary="Tag eltĂĄvolĂ­tĂĄsa", description="EltĂĄvolĂ­t egy tagot a garĂĄzsbĂłl (soft delete: status → archived).", ) async def remove_organization_member( org_id: int, member_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:manage-members")), ) -> Dict[str, Any]: """ Tag eltĂĄvolĂ­tĂĄsa a garĂĄzsbĂłl. Soft delete megközelĂ­tĂ©s: a rekord nem törlƑdik, csak a status mezƑ 'archived' Ă©rtĂ©kre vĂĄltozik. Ez megƑrzi a törtĂ©neti adatokat. """ # 1. EllenƑrizzĂŒk a tag rekordot member_stmt = select(OrganizationMember).where( OrganizationMember.id == member_id, OrganizationMember.organization_id == org_id, ) member_result = await db.execute(member_stmt) member = member_result.scalar_one_or_none() if not member: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tag nem talĂĄlhatĂł (ID: {member_id}) a szervezetben (ID: {org_id}).", ) # 2. Soft delete: archived stĂĄtusz member.status = "archived" await db.commit() logger.info( f"Admin {current_user.id} ({current_user.email}) removed member {member_id} from org {org_id} " f"(status → archived)" ) return { "status": "success", "message": f"Tag (ID: {member_id}) eltĂĄvolĂ­tva a szervezetbƑl (ID: {org_id}).", } # ============================================================================= # GET /{org_id}/vehicles — GarĂĄzs jĂĄrmƱveinek listĂĄzĂĄsa (Flotta Tab) # ============================================================================= class GarageVehicleItem(BaseModel): """Egy jĂĄrmƱ adatai a garĂĄzs flotta listĂĄjĂĄban.""" id: str license_plate: Optional[str] = None vin: Optional[str] = None brand: Optional[str] = None model: Optional[str] = None year: Optional[int] = None status: str = "active" branch_name: Optional[str] = None created_at: Optional[str] = None class GarageFleetResponse(BaseModel): """GarĂĄzs flotta jĂĄrmƱveinek vĂĄlasza.""" total: int vehicles: List[GarageVehicleItem] @router.get( "/{org_id}/vehicles", response_model=GarageFleetResponse, summary="GarĂĄzs jĂĄrmƱveinek listĂĄzĂĄsa", description="Visszaadja egy garĂĄzshoz tartozĂł összes jĂĄrmƱvet a Flotta Tab szĂĄmĂĄra.", ) async def get_organization_vehicles( org_id: int, skip: int = Query(0, ge=0, description="HĂĄny rekordot hagyjunk ki (lapozĂĄs)"), limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok szĂĄma"), db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("fleet:view")), ) -> GarageFleetResponse: """ GarĂĄzs jĂĄrmƱveinek lekĂ©rĂ©se. KĂ©t forrĂĄsbĂłl keres: 1. vehicle.assets.current_organization_id (elsƑdleges) 2. fleet.asset_assignments (törtĂ©neti kapcsolatok) """ # 1. EllenƑrizzĂŒk a szervezetet org_stmt = select(Organization).where(Organization.id == org_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem talĂĄlhatĂł ID-vel: {org_id}", ) # 2. JĂĄrmƱvek lekĂ©rĂ©se a vehicle.assets tĂĄblĂĄbĂłl from app.models.vehicle.asset import Asset from app.models.marketplace.organization import Branch from sqlalchemy import or_ # P0 DIRECT OWNERSHIP: Query by both owner_org_id AND current_organization_id # A jĂĄrmƱ akkor tartozik a garĂĄzshoz, ha: # - közvetlenĂŒl a tulajdonĂĄban van (owner_org_id), VAGY # - jelenleg oda van rendelve (current_organization_id) # NEM kell AssetAssignment JOIN — a direkt ownership mezƑk az Asset tĂĄblĂĄban vannak. # # P0 CRITICAL FIX: Filter by status == 'active' to exclude archived/draft vehicles # from the fleet list AND from the quota count. Without this filter, archived # vehicles still appear in the fleet tab and consume quota slots. ownership_filter = or_( Asset.owner_org_id == org_id, Asset.current_organization_id == org_id, ) active_filter = Asset.status == 'active' # P0 HOTFIX: Asset.branch relationship nem lĂ©tezik a modellben. # Explicit LEFT JOIN a fleet.branches tĂĄblĂĄhoz a branch_name lekĂ©rĂ©sĂ©hez. vehicle_stmt = ( select(Asset, Branch.name.label("branch_name")) .outerjoin(Branch, Asset.branch_id == Branch.id) .where(ownership_filter, active_filter) .order_by(Asset.created_at.desc()) .offset(skip) .limit(limit) ) vehicle_result = await db.execute(vehicle_stmt) rows = vehicle_result.all() # Teljes szĂĄmolĂĄs (also filtered by active status) count_stmt = select(func.count()).select_from( select(Asset).where(ownership_filter, active_filter).subquery() ) count_result = await db.execute(count_stmt) total = count_result.scalar() or 0 # 3. VĂĄlasz összeĂĄllĂ­tĂĄsa vehicle_list = [] for row in rows: v = row[0] # Asset object branch_name = row.branch_name # from the LEFT JOIN label vehicle_list.append(GarageVehicleItem( id=str(v.id), license_plate=v.license_plate, vin=v.vin, brand=v.brand, model=v.model, year=v.year_of_manufacture, status=v.status or "active", branch_name=branch_name, created_at=v.created_at.isoformat() if v.created_at else None, )) logger.info( f"Admin {current_user.id} ({current_user.email}) fetched fleet for org {org_id} " f"({org.full_name}) — {len(vehicle_list)} vehicles" ) return GarageFleetResponse( total=total, vehicles=vehicle_list, )