admin felület fejlesztése garázs, előfizeztési csomagok
This commit is contained in:
346
backend/app/api/v1/endpoints/admin_organizations.py
Normal file
346
backend/app/api/v1/endpoints/admin_organizations.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
🏢 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,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user