""" 🏢 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 from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select, func, 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.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.""" tier_id: int = Field(..., description="Az új SubscriptionTier ID-ja") 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." ) 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 SubscriptionSummary(BaseModel): """Előfizetés összefoglaló.""" tier_name: str = "Free/Fallback" tier_level: int = 0 valid_from: Optional[str] = None expires_at: Optional[str] = None is_active: bool = True asset_count: int = 0 asset_limit: int = 1 class GarageDetailsResponse(BaseModel): """Garázs részletes adatai (General Tab).""" # 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 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 # Előfizetés subscription: Optional[SubscriptionSummary] = None # Kapcsolattartó primary_contact: Optional[ContactPersonInfo] = None # Meta created_at: Optional[str] = None member_count: int = 0 # P0: Full member list with nested person data members: List[OrganizationMemberResponse] = [] # ============================================================================= # 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"), 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 query = select(Organization).options( selectinload(Organization.subscription_tier), ) # Szűrés if search_term: like_pattern = f"%{search_term}%" query = query.where( Organization.full_name.ilike(like_pattern) | Organization.name.ilike(like_pattern) | Organization.display_name.ilike(like_pattern) ) if status_filter: query = query.where(Organization.status == status_filter) if org_type_filter: query = query.where(Organization.org_type == org_type_filter) # Teljes számolás count_stmt = select(func.count()).select_from(query.subquery()) total_result = await db.execute(count_stmt) total = total_result.scalar() or 0 # Lapozás query = query.order_by(Organization.id.desc()).offset(skip).limit(limit) result = await db.execute(query) organizations = result.scalars().all() # Ö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 garage_list.append(GarageListItem( id=org.id, name=org.name, full_name=org.full_name, display_name=org.display_name, email=None, # Organization-nak nincs közvetlen email mezője 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, ) # ============================================================================= # 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 """ # 1. Szervezet lekérése subscription_tier kapcsolattal org_stmt = ( select(Organization) .options( selectinload(Organization.subscription_tier), ) .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és lekérése 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) ) sub_result = await db.execute(sub_stmt) active_sub = sub_result.scalar_one_or_none() # 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ó subscription_summary = None if active_sub and active_sub.tier: rules = active_sub.tier.rules or {} asset_limit = ( rules.get("max_vehicles") or active_sub.tier.feature_capabilities.get("max_vehicles") or 1 ) subscription_summary = SubscriptionSummary( tier_name=active_sub.tier.name, tier_level=active_sub.tier.tier_level, valid_from=active_sub.valid_from.isoformat() if active_sub.valid_from else None, expires_at=active_sub.valid_until.isoformat() if active_sub.valid_until else None, is_active=active_sub.is_active, asset_count=0, asset_limit=asset_limit, ) elif org.subscription_tier: rules = org.subscription_tier.rules or {} asset_limit = ( rules.get("max_vehicles") or org.subscription_tier.feature_capabilities.get("max_vehicles") or 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_limit=asset_limit, ) # 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, ) # 7. Tagok összeállítása nested Person adatokkal members_list: List[OrganizationMemberResponse] = [] 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( first_name=person_obj.first_name or "", last_name=person_obj.last_name or "", email=email, phone=person_obj.phone, ) 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, )) 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, subscription=subscription_summary, primary_contact=primary_contact, created_at=org.created_at.isoformat() if org.created_at else None, member_count=member_count, members=members_list, ) # ============================================================================= # 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).", ) 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. - Ellenőrzi, hogy a szervezet létezik-e. - Ellenőrzi, hogy a SubscriptionTier létezik-e. - Ha van aktív OrganizationSubscription, inaktiválja (is_active=False). - Létrehoz egy új OrganizationSubscription rekordot a megadott tier_id-val és opcionális expires_at dátummal. - Ha expires_at nincs megadva, a csomag rules.duration.days alapján számol. """ # 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. 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 now = datetime.utcnow() 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) # 5. Létrehozzuk az új subscription rekordot new_sub = OrganizationSubscription( org_id=org_id, tier_id=payload.tier_id, valid_from=now, valid_until=valid_until, is_active=True, ) db.add(new_sub) # 6. 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 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()}" ) 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, }, } # ============================================================================= # 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.", ) 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. - Ellenőrzi, hogy a szervezet létezik-e. - Ellenőrzi, hogy a user_id 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. Ellenőrizzük a felhasználót user_stmt = select(User).where(User.id == payload.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: {payload.user_id}", ) # 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 == payload.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: {payload.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=payload.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={payload.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}).", }