2145 lines
86 KiB
Python
2145 lines
86 KiB
Python
"""
|
|
🏢 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,
|
|
)
|