admin_users_personels
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user