""" 🏢 Admin Garages (Organizations) CRM API Végpontok: GET /admin/organizations — Garázsok listázása előfizetési adatokkal PATCH /admin/organizations/{org_id}/subscription — Előfizetés módosí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 from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload, joinedload, contains_eager 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.identity import User 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] # ============================================================================= # 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), admin: User = Depends(deps.get_current_admin), ) -> 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, ) # ============================================================================= # 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), admin: User = Depends(deps.get_current_admin), ) -> 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 {admin.id} ({admin.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, }, }