admin_users_personels

This commit is contained in:
Roo
2026-06-29 14:11:15 +00:00
parent 383e5511b6
commit df4a0f07ff
237 changed files with 17849 additions and 2934 deletions

View File

@@ -13,7 +13,7 @@ Végpontok:
from __future__ import annotations
import logging
from datetime import datetime
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -28,6 +28,7 @@ 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,
@@ -45,8 +46,24 @@ router = APIRouter()
class OrgSubscriptionUpdate(BaseModel):
"""Előfizetés módosításának kérése."""
tier_id: int = Field(..., description="Az új SubscriptionTier ID-ja")
"""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). "
@@ -65,6 +82,22 @@ class OrgSubscriptionUpdate(BaseModel):
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):
@@ -126,10 +159,21 @@ class ContactPersonInfo(BaseModel):
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
@@ -140,11 +184,13 @@ class SubscriptionSummary(BaseModel):
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: int
id: str
name: str
city: Optional[str] = None
is_active: bool = True
@@ -200,6 +246,21 @@ class GarageDetailsResponse(BaseModel):
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
# =============================================================================
@@ -447,6 +508,105 @@ def _extract_limit(
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)
# =============================================================================
@@ -499,14 +659,22 @@ async def get_organization_details(
# 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.
now = datetime.utcnow()
# 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,
OrganizationSubscription.valid_until >= now,
or_(
OrganizationSubscription.valid_until >= now,
OrganizationSubscription.valid_until.is_(None),
),
)
.order_by(OrganizationSubscription.id.desc())
)
@@ -514,10 +682,14 @@ async def get_organization_details(
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
from app.models.vehicle.vehicle import Vehicle # noqa: E402
vehicle_count_stmt = select(func.count(Vehicle.id)).where(
Vehicle.organization_id == org_id,
Vehicle.is_deleted == False,
# 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
@@ -532,10 +704,10 @@ async def get_organization_details(
branch_rows = branches_result.scalars().all()
branches_list = [
BranchBrief(
id=b.id,
id=str(b.id),
name=b.name,
city=b.city,
is_active=b.is_active,
is_active=(b.status == "active") if b.status else True,
)
for b in branch_rows
]
@@ -589,7 +761,9 @@ async def get_organization_details(
key=lambda s: s.tier.tier_level if s.tier else 0,
reverse=True,
)
primary_sub = base_subs_sorted[0] if base_subs_sorted else (active_subs[0] if active_subs else None)
# 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) ──
@@ -610,7 +784,7 @@ async def get_organization_details(
base_users = _extract_limit(
primary_rules,
["max_users", "max_members"],
_extract_limit(primary_fc, ["max_users", "max_members"], 0),
_extract_limit(primary_fc, ["max_users", "max_members"], 1),
)
# ── Aggregate extra_allowances from ALL active subscriptions ──
@@ -652,9 +826,49 @@ async def get_organization_details(
# 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,
@@ -663,6 +877,7 @@ async def get_organization_details(
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)
@@ -679,7 +894,7 @@ async def get_organization_details(
)
base_users = _extract_limit(
rules, ["max_users", "max_members"],
_extract_limit(fc, ["max_users", "max_members"], 0),
_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)
@@ -694,6 +909,52 @@ async def get_organization_details(
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:
@@ -859,6 +1120,8 @@ async def get_organization_details(
members=members_list,
# ── P0 ADD-ON UPGRADE: Branches list ──
branches=branches_list,
# ── P0 AUDIT TRAIL: Subscription history ──
subscription_history=subscription_history,
)
@@ -1105,7 +1368,8 @@ async def update_organization(
"/{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).",
"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,
@@ -1117,13 +1381,39 @@ async def update_org_subscription(
"""
Garázs előfizetésének módosítása.
- Ellenőrzi, hogy a szervezet létezik-e.
- Ellenőrzi, hogy a SubscriptionTier létezik-e.
- Ha van aktív OrganizationSubscription, inaktiválja (is_active=False).
- Létrehoz egy új OrganizationSubscription rekordot a megadott tier_id-val
és opcionális expires_at dátummal.
- Ha expires_at nincs megadva, a csomag rules.duration.days alapján számol.
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)
@@ -1135,6 +1425,198 @@ async def update_org_subscription(
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)
@@ -1157,7 +1639,6 @@ async def update_org_subscription(
)
# 4. Számoljuk a lejárati dátumot
now = datetime.utcnow()
if payload.expires_at:
valid_until = payload.expires_at
else:
@@ -1168,31 +1649,73 @@ async def update_org_subscription(
from datetime import timedelta
valid_until = now + timedelta(days=days)
# 5. P0 ADD-ON: Összeállítjuk az extra_allowances JSONB-t
extra_allowances = {}
if payload.extra_vehicles > 0:
extra_allowances["extra_vehicles"] = payload.extra_vehicles
if payload.extra_branches > 0:
extra_allowances["extra_branches"] = payload.extra_branches
if payload.extra_users > 0:
extra_allowances["extra_users"] = payload.extra_users
# Normalize base subscription expiration
valid_until = normalize_to_end_of_day(valid_until)
# 6. Létrehozzuk az új subscription rekordot
# 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=extra_allowances,
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)
@@ -1200,7 +1723,7 @@ async def update_org_subscription(
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"extra_allowances={extra_allowances}"
f"addons={addon_list}"
)
return {
@@ -1214,8 +1737,9 @@ async def update_org_subscription(
"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": extra_allowances,
"extra_allowances": {},
},
"addons": addon_list,
}
@@ -1562,17 +2086,22 @@ async def get_organization_vehicles(
# - 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)
.where(ownership_filter, active_filter)
.order_by(Asset.created_at.desc())
.offset(skip)
.limit(limit)
@@ -1580,9 +2109,9 @@ async def get_organization_vehicles(
vehicle_result = await db.execute(vehicle_stmt)
rows = vehicle_result.all()
# Teljes számolás
# Teljes számolás (also filtered by active status)
count_stmt = select(func.count()).select_from(
select(Asset).where(ownership_filter).subquery()
select(Asset).where(ownership_filter, active_filter).subquery()
)
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0