Files
service-finder/backend/app/api/v1/endpoints/admin_organizations.py
2026-07-01 19:49:58 +00:00

2204 lines
91 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.services.address_manager import AddressManager
from app.models.vehicle.history import AuditLog, LogSeverity
from app.schemas.address import AddressIn, AddressOut
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) — denormalizált mezők (backward compatible)
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
# P0: Unified AddressOut nested object
address_detail: Optional[AddressOut] = 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) — denormalizált mezők (backward compatible) ──
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
# P0: Unified AddressOut nested object for billing address
billing_address_detail: Optional[AddressOut] = None
# ── Értesítési cím (Notification Address) — denormalizált mezők (backward compatible) ──
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
# P0: Unified AddressOut nested object for notification address
notification_address_detail: Optional[AddressOut] = 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)
# P1 FIX: Use relationship-based city instead of ghost column org.address_city
resolved_city = org.address.city if org.address 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=resolved_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"
)
# ── P0 REFACTORED: Build unified AddressOut objects from relationships ──
address_detail = None
if org.address:
address_detail = AddressOut.model_validate(org.address)
billing_address_detail = None
if org.billing_address:
billing_address_detail = AddressOut.model_validate(org.billing_address)
notification_address_detail = None
if org.notification_address:
notification_address_detail = AddressOut.model_validate(org.notification_address)
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),
# ── P0 REFACTORED: Denormalized fields populated from relationship ──
address_city=org.address.city if org.address else None,
address_zip=org.address.zip if org.address else None,
address_street_name=org.address.street_name if org.address else None,
address_street_type=org.address.street_type if org.address else None,
address_house_number=org.address.house_number if org.address else None,
address_detail=address_detail,
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_address.zip if org.billing_address else None,
billing_city=org.billing_address.city if org.billing_address else None,
billing_street_name=org.billing_address.street_name if org.billing_address else None,
billing_street_type=org.billing_address.street_type if org.billing_address else None,
billing_house_number=org.billing_address.house_number if org.billing_address else None,
billing_address_detail=billing_address_detail,
# ── Értesítési cím ──
notification_zip=org.notification_address.zip if org.notification_address else None,
notification_city=org.notification_address.city if org.notification_address else None,
notification_street_name=org.notification_address.street_name if org.notification_address else None,
notification_street_type=org.notification_address.street_type if org.notification_address else None,
notification_house_number=org.notification_address.house_number if org.notification_address else None,
notification_address_detail=notification_address_detail,
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) — DEPRECATED: Use address_detail instead ──
address_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd az address_detail mezőt! Irányítószám")
address_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd az address_detail mezőt! Város")
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd az address_detail mezőt! Utca neve")
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd az address_detail mezőt! Utca típusa (utca, tér, stb.)")
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd az address_detail mezőt! Házszám")
# P0: Unified AddressIn nested object for primary address
address_detail: Optional[AddressIn] = None
# ── 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) — DEPRECATED: Use billing_address_detail instead ──
billing_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási irányítószám")
billing_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási város")
billing_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási utca neve")
billing_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási utca típusa")
billing_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási házszám")
# P0: Unified AddressIn nested object for billing address
billing_address_detail: Optional[AddressIn] = None
# ── Értesítési cím (Notification Address) — DEPRECATED: Use notification_address_detail instead ──
notification_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési irányítószám")
notification_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési város")
notification_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési utca neve")
notification_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési utca típusa")
notification_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési házszám")
# P0: Unified AddressIn nested object for notification address
notification_address_detail: Optional[AddressIn] = None
@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. P0 REFACTORED: Address FK logic via AddressManager
raw_data = payload.model_dump(exclude_none=True)
# ── Primary address via AddressManager ──
address_detail: Optional[AddressIn] = raw_data.pop("address_detail", None)
if address_detail:
org.address_id = await AddressManager.create_or_update(
db, org.address_id, address_detail
)
# ── Billing address via AddressManager ──
billing_address_detail: Optional[AddressIn] = raw_data.pop("billing_address_detail", None)
if billing_address_detail:
org.billing_address_id = await AddressManager.create_or_update(
db, org.billing_address_id, billing_address_detail
)
# ── Notification address via AddressManager ──
notification_address_detail: Optional[AddressIn] = raw_data.pop("notification_address_detail", None)
if notification_address_detail:
org.notification_address_id = await AddressManager.create_or_update(
db, org.notification_address_id, notification_address_detail
)
# 3. Összegyűjtjük a változásokat (non-address fields)
update_fields = {}
for field_name in raw_data:
value = raw_data[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())}"
)
# ── P0 REFACTORED: Build unified AddressOut from relationship ──
resp_address_detail = None
if org.address:
resp_address_detail = AddressOut.model_validate(org.address)
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_detail": resp_address_detail,
"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,
)