admin_users_personels
This commit is contained in:
@@ -5,7 +5,7 @@ from app.api.v1.endpoints import (
|
||||
services, admin, expenses, evidence, social, security,
|
||||
billing, finance_admin, analytics, vehicles, system_parameters,
|
||||
gamification, translations, users, reports, dictionaries,
|
||||
admin_packages, admin_services, admin_organizations, constants, providers,
|
||||
admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
|
||||
subscriptions, marketing, admin_permissions, regions,
|
||||
)
|
||||
|
||||
@@ -36,6 +36,8 @@ api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Di
|
||||
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
|
||||
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
|
||||
api_router.include_router(admin_organizations.router, prefix="/admin/organizations", tags=["Admin Garages CRM"])
|
||||
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["Admin Users"])
|
||||
api_router.include_router(admin_persons.router, prefix="/admin/persons", tags=["Admin Persons"])
|
||||
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
|
||||
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
|
||||
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
|
||||
|
||||
@@ -497,6 +497,7 @@ async def list_users(
|
||||
role: Optional[str] = Query(None, description="Szerepkör szűrés (pl. admin, user)"),
|
||||
is_active: Optional[bool] = Query(None, description="Aktív státusz szűrés"),
|
||||
is_deleted: Optional[bool] = Query(None, description="Törölt státusz szűrés"),
|
||||
plan_filter: Optional[str] = Query(None, description="Csomag szűrés (pl. FREE, PREMIUM, ENTERPRISE)"),
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("user:view")),
|
||||
@@ -580,8 +581,10 @@ async def list_users(
|
||||
)
|
||||
|
||||
if role:
|
||||
# Case-insensitive role matching
|
||||
role_upper = role.upper()
|
||||
try:
|
||||
role_enum = UserRole(role)
|
||||
role_enum = UserRole(role_upper)
|
||||
query = query.where(User.role == role_enum)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
@@ -592,6 +595,8 @@ async def list_users(
|
||||
query = query.where(User.is_active == is_active)
|
||||
if is_deleted is not None:
|
||||
query = query.where(User.is_deleted == is_deleted)
|
||||
if plan_filter:
|
||||
query = query.where(User.subscription_plan == plan_filter.upper())
|
||||
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
@@ -625,6 +630,12 @@ async def list_users(
|
||||
parts.append(str(address.house_number))
|
||||
address_str = ', '.join(parts) if parts else None
|
||||
|
||||
# Személy nevének összefűzése
|
||||
person_name = None
|
||||
if person:
|
||||
parts = [p for p in [person.last_name, person.first_name] if p]
|
||||
person_name = ' '.join(parts) if parts else None
|
||||
|
||||
user_list.append({
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
@@ -637,6 +648,7 @@ async def list_users(
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
"deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
|
||||
# Person adatok
|
||||
"person_name": person_name,
|
||||
"first_name": person.first_name if person else None,
|
||||
"last_name": person.last_name if person else None,
|
||||
"phone": person.phone if person else None,
|
||||
@@ -656,6 +668,148 @@ async def list_users(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users/stats", tags=["User Management"])
|
||||
async def get_user_stats(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("user:view")),
|
||||
):
|
||||
"""
|
||||
Felhasználói statisztikák a Dashboard számára.
|
||||
|
||||
Visszaadja az összesítő adatokat: teljes, aktív, törölt, kitiltott felhasználók,
|
||||
mai/héti/havi új regisztrációk, szerepkör és előfizetés szerinti megoszlás,
|
||||
nyelvi statisztikák, Person kapcsolatok, szervezeti adatok.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start = today_start - timedelta(days=today_start.weekday())
|
||||
month_start = today_start.replace(day=1)
|
||||
|
||||
# === ALAP STATISZTIKÁK ===
|
||||
total_result = await db.execute(select(func.count(User.id)))
|
||||
total_users = total_result.scalar() or 0
|
||||
|
||||
active_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.is_active == True, User.is_deleted == False)
|
||||
)
|
||||
active_users = active_result.scalar() or 0
|
||||
|
||||
deleted_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.is_deleted == True)
|
||||
)
|
||||
deleted_users = deleted_result.scalar() or 0
|
||||
|
||||
# Banned = is_active=False AND is_deleted=False (deactivated but not deleted)
|
||||
banned_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.is_active == False, User.is_deleted == False)
|
||||
)
|
||||
banned_users = banned_result.scalar() or 0
|
||||
|
||||
# === ÚJ REGISZTRÁCIÓK ===
|
||||
new_today_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.created_at >= today_start)
|
||||
)
|
||||
new_users_today = new_today_result.scalar() or 0
|
||||
|
||||
new_week_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.created_at >= week_start)
|
||||
)
|
||||
new_users_this_week = new_week_result.scalar() or 0
|
||||
|
||||
new_month_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.created_at >= month_start)
|
||||
)
|
||||
new_users_this_month = new_month_result.scalar() or 0
|
||||
|
||||
# === SZEREPKÖR SZERINTI MEGOSZLÁS ===
|
||||
role_result = await db.execute(
|
||||
select(User.role, func.count(User.id)).group_by(User.role)
|
||||
)
|
||||
users_by_role = {}
|
||||
for row in role_result:
|
||||
role_key = row[0].value if hasattr(row[0], "value") else str(row[0])
|
||||
users_by_role[role_key] = row[1]
|
||||
|
||||
# === ELŐFIZETÉS SZERINTI MEGOSZLÁS ===
|
||||
plan_result = await db.execute(
|
||||
select(User.subscription_plan, func.count(User.id)).group_by(User.subscription_plan)
|
||||
)
|
||||
users_by_plan = {row[0]: row[1] for row in plan_result}
|
||||
|
||||
# === NYELV SZERINTI MEGOSZLÁS ===
|
||||
lang_result = await db.execute(
|
||||
select(User.preferred_language, func.count(User.id)).group_by(User.preferred_language)
|
||||
)
|
||||
users_by_language = {row[0]: row[1] for row in lang_result}
|
||||
|
||||
# === PERSON KAPCSOLATOK ===
|
||||
with_person_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.person_id.isnot(None))
|
||||
)
|
||||
users_with_person = with_person_result.scalar() or 0
|
||||
|
||||
users_without_person = total_users - users_with_person
|
||||
|
||||
# === SZERVEZETI STATISZTIKÁK ===
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
|
||||
org_count_result = await db.execute(
|
||||
select(func.count(Organization.id)).where(Organization.is_deleted == False)
|
||||
)
|
||||
active_organizations_count = org_count_result.scalar() or 0
|
||||
|
||||
memberships_result = await db.execute(
|
||||
select(func.count(OrganizationMember.id))
|
||||
)
|
||||
total_memberships = memberships_result.scalar() or 0
|
||||
|
||||
# === JÁRMŰ STATISZTIKÁK ===
|
||||
from app.models.vehicle.asset import Asset
|
||||
|
||||
vehicle_count_result = await db.execute(
|
||||
select(func.count(Asset.id))
|
||||
)
|
||||
total_vehicles = vehicle_count_result.scalar() or 0
|
||||
|
||||
# === REGISZTRÁCIÓS TREND (utolsó 30 nap) ===
|
||||
thirty_days_ago = today_start - timedelta(days=30)
|
||||
trend_result = await db.execute(
|
||||
text("""
|
||||
SELECT DATE(created_at) as reg_date, COUNT(*) as cnt
|
||||
FROM identity.users
|
||||
WHERE created_at >= :start_date
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY reg_date
|
||||
"""),
|
||||
{"start_date": thirty_days_ago}
|
||||
)
|
||||
registration_trend = [
|
||||
{"date": row[0].isoformat() if hasattr(row[0], 'isoformat') else str(row[0]), "count": row[1]}
|
||||
for row in trend_result
|
||||
]
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"deleted_users": deleted_users,
|
||||
"banned_users": banned_users,
|
||||
"new_users_today": new_users_today,
|
||||
"new_users_this_week": new_users_this_week,
|
||||
"new_users_this_month": new_users_this_month,
|
||||
"users_by_role": users_by_role,
|
||||
"users_by_plan": users_by_plan,
|
||||
"users_by_language": users_by_language,
|
||||
"users_with_person": users_with_person,
|
||||
"users_without_person": users_without_person,
|
||||
"total_vehicles": total_vehicles,
|
||||
"active_organizations_count": active_organizations_count,
|
||||
"total_memberships": total_memberships,
|
||||
"registration_trend": registration_trend,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/bulk-action", tags=["User Management"])
|
||||
async def bulk_user_action(
|
||||
request: BulkActionRequest,
|
||||
@@ -732,11 +886,11 @@ async def bulk_user_action(
|
||||
affected_count += 1
|
||||
|
||||
audit_log = SecurityAuditLog(
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
action=f"bulk_{request.action}",
|
||||
details=f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}",
|
||||
payload_before={},
|
||||
payload_after={"details": f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}"},
|
||||
is_critical=(request.action in ("hard_delete", "ban")),
|
||||
ip_address="admin_api"
|
||||
)
|
||||
db.add(audit_log)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -229,6 +229,7 @@ async def create_package(
|
||||
|
||||
tier = SubscriptionTier(
|
||||
name=payload.name,
|
||||
type=payload.type,
|
||||
rules=rules_dict,
|
||||
is_custom=payload.is_custom,
|
||||
tier_level=payload.tier_level,
|
||||
@@ -309,6 +310,9 @@ async def update_package(
|
||||
)
|
||||
tier.name = update_data["name"]
|
||||
|
||||
if "type" in update_data and update_data["type"] is not None:
|
||||
tier.type = update_data["type"]
|
||||
|
||||
if "rules" in update_data and update_data["rules"] is not None:
|
||||
# ── P0 CRITICAL FIX: Deep merge instead of wholesale replace ──
|
||||
# Previously: tier.rules = new_rules (wholesale replacement)
|
||||
|
||||
1084
backend/app/api/v1/endpoints/admin_persons.py
Normal file
1084
backend/app/api/v1/endpoints/admin_persons.py
Normal file
File diff suppressed because it is too large
Load Diff
646
backend/app/api/v1/endpoints/admin_users.py
Normal file
646
backend/app/api/v1/endpoints/admin_users.py
Normal file
@@ -0,0 +1,646 @@
|
||||
"""
|
||||
👤 Admin Users API
|
||||
|
||||
Végpontok:
|
||||
GET /admin/users/stats — Felhasználói statisztikák (dashboard)
|
||||
GET /admin/users/{user_id} — Felhasználó részletes adatai (User + Person + Address)
|
||||
PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által
|
||||
GET /admin/users/{user_id}/memberships — Felhasználó szervezeti tagságai
|
||||
|
||||
Megjegyzés: A GET /admin/users (lista) és POST /admin/users/bulk-action
|
||||
végpontok a admin.py modulban találhatók, mivel az a /admin prefix alá
|
||||
van regisztrálva, és a /users, /users/bulk-action útvonalakat kezeli.
|
||||
"""
|
||||
|
||||
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 pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy import select, func, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.api import deps
|
||||
from app.db.session import get_db
|
||||
from app.models.identity import User, Person, UserRole
|
||||
from app.models.identity.address import Address
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.vehicle.history import AuditLog, LogSeverity
|
||||
from app.schemas.user import UserResponse
|
||||
from app.schemas.organization import OrganizationMemberResponse
|
||||
|
||||
logger = logging.getLogger("admin-users")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class RegistrationTrendItem(BaseModel):
|
||||
"""Napi regisztrációs trend elem."""
|
||||
date: str = Field(..., description="Dátum (YYYY-MM-DD)")
|
||||
count: int = Field(..., description="Regisztrációk száma")
|
||||
|
||||
|
||||
class UserStatsResponse(BaseModel):
|
||||
"""Felhasználói statisztikák a dashboard számára."""
|
||||
total_users: int = Field(..., description="Összes felhasználó")
|
||||
active_users: int = Field(..., description="Aktív felhasználók")
|
||||
deleted_users: int = Field(..., description="Törölt felhasználók")
|
||||
banned_users: int = Field(..., description="Kitiltott felhasználók")
|
||||
new_users_today: int = Field(..., description="Ma regisztrált felhasználók")
|
||||
new_users_this_week: int = Field(..., description="Ezen a héten regisztrált felhasználók")
|
||||
new_users_this_month: int = Field(..., description="Ebben a hónapban regisztrált felhasználók")
|
||||
users_by_role: Dict[str, int] = Field(..., description="Felhasználók szerepkör szerint")
|
||||
users_by_plan: Dict[str, int] = Field(..., description="Felhasználók előfizetési csomag szerint")
|
||||
users_by_language: Dict[str, int] = Field(..., description="Felhasználók nyelv szerint")
|
||||
users_with_person: int = Field(..., description="Person rekorddal rendelkező felhasználók")
|
||||
users_without_person: int = Field(..., description="Person rekord nélküli felhasználók")
|
||||
registration_trend: List[RegistrationTrendItem] = Field(
|
||||
default_factory=list, description="Regisztrációs trend (utolsó 30 nap)"
|
||||
)
|
||||
active_organizations_count: int = Field(..., description="Aktív szervezetek száma")
|
||||
total_memberships: int = Field(..., description="Összes szervezeti tagság")
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
"""Admin által szerkeszthető felhasználói mezők.
|
||||
|
||||
Csak a megadott mezők módosíthatók admin felületről.
|
||||
A role_id módosítása csak SUPERADMIN számára engedélyezett.
|
||||
"""
|
||||
email: Optional[EmailStr] = Field(default=None, description="Email cím (csak admin/superadmin)")
|
||||
is_active: Optional[bool] = Field(default=None, description="Aktív státusz (letiltás/feloldás)")
|
||||
is_vip: Optional[bool] = Field(default=None, description="VIP státusz")
|
||||
preferred_language: Optional[str] = Field(default=None, max_length=5, description="Nyelv")
|
||||
region_code: Optional[str] = Field(default=None, max_length=5, description="Régió kód")
|
||||
preferred_currency: Optional[str] = Field(default=None, max_length=3, description="Pénznem")
|
||||
subscription_plan: Optional[str] = Field(default=None, max_length=30, description="Előfizetési csomag")
|
||||
subscription_expires_at: Optional[datetime] = Field(default=None, description="Előfizetés lejárata")
|
||||
scope_level: Optional[str] = Field(default=None, max_length=30, description="Scope szint")
|
||||
scope_id: Optional[str] = Field(default=None, max_length=50, description="Scope azonosító")
|
||||
role_id: Optional[int] = Field(default=None, description="RBAC szerepkör ID (csak superadmin)")
|
||||
custom_permissions: Optional[Dict[str, Any]] = Field(default=None, description="Egyedi jogosultságok")
|
||||
|
||||
# Person almzők
|
||||
person: Optional["AdminPersonUpdate"] = Field(default=None, description="Személyes adatok")
|
||||
|
||||
|
||||
class AdminPersonUpdate(BaseModel):
|
||||
"""Admin által szerkeszthető személyes adatok."""
|
||||
last_name: Optional[str] = Field(default=None, description="Vezetéknév")
|
||||
first_name: Optional[str] = Field(default=None, description="Keresztnév")
|
||||
phone: Optional[str] = Field(default=None, description="Telefonszám")
|
||||
mothers_last_name: Optional[str] = Field(default=None, description="Anyja születési vezetékneve")
|
||||
mothers_first_name: Optional[str] = Field(default=None, description="Anyja születési keresztneve")
|
||||
birth_place: Optional[str] = Field(default=None, description="Születési hely")
|
||||
birth_date: Optional[datetime] = Field(default=None, description="Születési dátum")
|
||||
identity_docs: Optional[Dict[str, Any]] = Field(default=None, description="Személyi okmány adatok")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=UserStatsResponse,
|
||||
summary="Felhasználói statisztikák lekérése",
|
||||
description=(
|
||||
"Visszaadja a felhasználók összesített statisztikáit a dashboard számára, "
|
||||
"beleértve a szerepkör, előfizetési csomag és nyelv szerinti megoszlást, "
|
||||
"valamint a regisztrációs trendet. Csak admin/staff jogosultsággal érhető el."
|
||||
),
|
||||
)
|
||||
async def get_user_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("user:view")),
|
||||
) -> UserStatsResponse:
|
||||
"""
|
||||
GET /admin/users/stats
|
||||
|
||||
Összesített felhasználói statisztikák a dashboard számára.
|
||||
Az összes lekérdezés egyetlen tranzakcióban fut le.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start = today_start - timedelta(days=today_start.weekday())
|
||||
month_start = today_start.replace(day=1)
|
||||
|
||||
# ── 1. Alap statisztikák ──
|
||||
total_users_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
)
|
||||
total_users = total_users_result.scalar() or 0
|
||||
|
||||
active_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.is_active.is_(True), User.is_deleted.is_(False))
|
||||
)
|
||||
active_users = active_users_result.scalar() or 0
|
||||
|
||||
deleted_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.is_deleted.is_(True))
|
||||
)
|
||||
deleted_users = deleted_users_result.scalar() or 0
|
||||
|
||||
banned_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.is_active.is_(False),
|
||||
User.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
banned_users = banned_users_result.scalar() or 0
|
||||
|
||||
# ── 2. Új felhasználók ──
|
||||
new_today_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.created_at >= today_start)
|
||||
)
|
||||
new_users_today = new_today_result.scalar() or 0
|
||||
|
||||
new_week_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.created_at >= week_start)
|
||||
)
|
||||
new_users_this_week = new_week_result.scalar() or 0
|
||||
|
||||
new_month_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.created_at >= month_start)
|
||||
)
|
||||
new_users_this_month = new_month_result.scalar() or 0
|
||||
|
||||
# ── 3. Felhasználók szerepkör szerint ──
|
||||
role_rows = await db.execute(
|
||||
select(User.role, func.count(User.id).label("cnt"))
|
||||
.group_by(User.role)
|
||||
)
|
||||
users_by_role: Dict[str, int] = {}
|
||||
for row in role_rows:
|
||||
role_name = row.role.value if hasattr(row.role, 'value') else str(row.role)
|
||||
users_by_role[role_name.lower()] = row.cnt
|
||||
|
||||
# ── 4. Felhasználók előfizetési csomag szerint ──
|
||||
plan_rows = await db.execute(
|
||||
select(User.subscription_plan, func.count(User.id).label("cnt"))
|
||||
.group_by(User.subscription_plan)
|
||||
)
|
||||
users_by_plan: Dict[str, int] = {}
|
||||
for row in plan_rows:
|
||||
users_by_plan[row.subscription_plan.lower()] = row.cnt
|
||||
|
||||
# ── 5. Felhasználók nyelv szerint ──
|
||||
lang_rows = await db.execute(
|
||||
select(User.preferred_language, func.count(User.id).label("cnt"))
|
||||
.group_by(User.preferred_language)
|
||||
)
|
||||
users_by_language: Dict[str, int] = {}
|
||||
for row in lang_rows:
|
||||
users_by_language[row.preferred_language] = row.cnt
|
||||
|
||||
# ── 6. Person kapcsolat ──
|
||||
with_person_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.person_id.isnot(None))
|
||||
)
|
||||
users_with_person = with_person_result.scalar() or 0
|
||||
|
||||
without_person_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.person_id.is_(None))
|
||||
)
|
||||
users_without_person = without_person_result.scalar() or 0
|
||||
|
||||
# ── 7. Regisztrációs trend (utolsó 30 nap) ──
|
||||
thirty_days_ago = today_start - timedelta(days=30)
|
||||
day_trunc = func.date_trunc("day", User.created_at)
|
||||
trend_rows = await db.execute(
|
||||
select(
|
||||
day_trunc.label("day"),
|
||||
func.count(User.id).label("cnt"),
|
||||
)
|
||||
.where(User.created_at >= thirty_days_ago)
|
||||
.group_by(day_trunc)
|
||||
.order_by(day_trunc)
|
||||
)
|
||||
registration_trend: List[RegistrationTrendItem] = []
|
||||
for row in trend_rows:
|
||||
day_str = row.day.strftime("%Y-%m-%d") if row.day else ""
|
||||
registration_trend.append(RegistrationTrendItem(date=day_str, count=row.cnt))
|
||||
|
||||
# ── 8. Szervezeti statisztikák ──
|
||||
active_orgs_result = await db.execute(
|
||||
select(func.count(Organization.id))
|
||||
.where(Organization.is_anonymized.is_(False))
|
||||
)
|
||||
active_organizations_count = active_orgs_result.scalar() or 0
|
||||
|
||||
total_memberships_result = await db.execute(
|
||||
select(func.count(OrganizationMember.id))
|
||||
)
|
||||
total_memberships = total_memberships_result.scalar() or 0
|
||||
|
||||
return UserStatsResponse(
|
||||
total_users=total_users,
|
||||
active_users=active_users,
|
||||
deleted_users=deleted_users,
|
||||
banned_users=banned_users,
|
||||
new_users_today=new_users_today,
|
||||
new_users_this_week=new_users_this_week,
|
||||
new_users_this_month=new_users_this_month,
|
||||
users_by_role=users_by_role,
|
||||
users_by_plan=users_by_plan,
|
||||
users_by_language=users_by_language,
|
||||
users_with_person=users_with_person,
|
||||
users_without_person=users_without_person,
|
||||
registration_trend=registration_trend,
|
||||
active_organizations_count=active_organizations_count,
|
||||
total_memberships=total_memberships,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}",
|
||||
response_model=UserResponse,
|
||||
summary="Felhasználó részletes adatainak lekérése",
|
||||
description=(
|
||||
"Visszaadja egy felhasználó összes adatát, beleértve a Person rekordot "
|
||||
"és a beágyazott Address adatokat. Csak admin/staff jogosultsággal érhető el."
|
||||
),
|
||||
)
|
||||
async def get_admin_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("user:view")),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
GET /admin/users/{user_id}
|
||||
|
||||
Lekérdezi a felhasználót a megadott ID alapján, eager loading-gal betölti
|
||||
a Person és Address kapcsolatokat, majd visszaadja a teljes UserResponse-t.
|
||||
|
||||
Args:
|
||||
user_id: A lekérdezni kívánt felhasználó ID-ja.
|
||||
|
||||
Returns:
|
||||
UserResponse séma szerinti felhasználói adatok.
|
||||
|
||||
Raises:
|
||||
HTTPException 404: Ha a felhasználó nem található vagy törölve lett.
|
||||
"""
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.id == user_id)
|
||||
.options(
|
||||
joinedload(User.person)
|
||||
.joinedload(Person.address)
|
||||
.joinedload(Address.postal_code)
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user = result.unique().scalar_one_or_none()
|
||||
|
||||
if not user or user.is_deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Felhasználó nem található.",
|
||||
)
|
||||
|
||||
# Reuse the existing _build_user_response helper from users.py
|
||||
from app.api.v1.endpoints.users import _build_user_response
|
||||
|
||||
response_data = await _build_user_response(user, db=db)
|
||||
return UserResponse.model_validate(response_data)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{user_id}",
|
||||
response_model=Dict[str, Any],
|
||||
summary="Felhasználó szerkesztése",
|
||||
description=(
|
||||
"Admin általi felhasználó szerkesztés. Csak a megadott mezők módosíthatók. "
|
||||
"A role_id módosítása csak SUPERADMIN számára engedélyezett. "
|
||||
"Szuperadmin felhasználó nem módosítható."
|
||||
),
|
||||
)
|
||||
async def update_admin_user(
|
||||
user_id: int,
|
||||
update_data: AdminUserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("user:edit")),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
PATCH /admin/users/{user_id}
|
||||
|
||||
Admin általi felhasználó szerkesztés. Csak a megadott mezők módosíthatók.
|
||||
A role_id módosítása csak SUPERADMIN számára engedélyezett.
|
||||
Szuperadmin felhasználó nem módosítható.
|
||||
|
||||
Args:
|
||||
user_id: A módosítandó felhasználó ID-ja.
|
||||
update_data: A módosítandó mezők.
|
||||
|
||||
Returns:
|
||||
UserResponse séma szerinti frissített felhasználói adatok.
|
||||
|
||||
Raises:
|
||||
HTTPException 404: Ha a felhasználó nem található vagy törölve lett.
|
||||
HTTPException 403: Ha a módosítás nem engedélyezett.
|
||||
"""
|
||||
# ── 1. Lekérdezzük a felhasználót ──
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.id == user_id)
|
||||
.options(
|
||||
joinedload(User.person)
|
||||
.joinedload(Person.address)
|
||||
.joinedload(Address.postal_code)
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user = result.unique().scalar_one_or_none()
|
||||
|
||||
if not user or user.is_deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Felhasználó nem található.",
|
||||
)
|
||||
|
||||
# ── 2. Biztonsági ellenőrzések ──
|
||||
# Szuperadmin felhasználó nem módosítható (még másik superadmin által sem)
|
||||
if user.role == UserRole.SUPERADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Szuperadmin felhasználó nem módosítható.",
|
||||
)
|
||||
|
||||
# role_id módosítása csak SUPERADMIN számára engedélyezett
|
||||
if update_data.role_id is not None and current_user.role != UserRole.SUPERADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak SUPERADMIN módosíthatja a szerepkört.",
|
||||
)
|
||||
|
||||
# ── 3. Összegyűjtjük a változásokat audit loghoz ──
|
||||
old_data: Dict[str, Any] = {}
|
||||
new_data: Dict[str, Any] = {}
|
||||
|
||||
# ── 4. User mezők frissítése ──
|
||||
update_fields: Dict[str, Any] = {}
|
||||
|
||||
if update_data.email is not None:
|
||||
# Email egyediség ellenőrzés
|
||||
existing = await db.execute(
|
||||
select(User).where(User.email == update_data.email, User.id != user_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Ez az email cím már használatban van.",
|
||||
)
|
||||
old_data["email"] = user.email
|
||||
new_data["email"] = update_data.email
|
||||
update_fields["email"] = update_data.email
|
||||
|
||||
if update_data.is_active is not None:
|
||||
old_data["is_active"] = user.is_active
|
||||
new_data["is_active"] = update_data.is_active
|
||||
update_fields["is_active"] = update_data.is_active
|
||||
|
||||
if update_data.is_vip is not None:
|
||||
old_data["is_vip"] = user.is_vip
|
||||
new_data["is_vip"] = update_data.is_vip
|
||||
update_fields["is_vip"] = update_data.is_vip
|
||||
|
||||
if update_data.preferred_language is not None:
|
||||
old_data["preferred_language"] = user.preferred_language
|
||||
new_data["preferred_language"] = update_data.preferred_language
|
||||
update_fields["preferred_language"] = update_data.preferred_language
|
||||
|
||||
if update_data.region_code is not None:
|
||||
old_data["region_code"] = user.region_code
|
||||
new_data["region_code"] = update_data.region_code
|
||||
update_fields["region_code"] = update_data.region_code
|
||||
|
||||
if update_data.preferred_currency is not None:
|
||||
old_data["preferred_currency"] = user.preferred_currency
|
||||
new_data["preferred_currency"] = update_data.preferred_currency
|
||||
update_fields["preferred_currency"] = update_data.preferred_currency
|
||||
|
||||
if update_data.subscription_plan is not None:
|
||||
old_data["subscription_plan"] = user.subscription_plan
|
||||
new_data["subscription_plan"] = update_data.subscription_plan
|
||||
update_fields["subscription_plan"] = update_data.subscription_plan
|
||||
|
||||
if update_data.subscription_expires_at is not None:
|
||||
old_data["subscription_expires_at"] = (
|
||||
user.subscription_expires_at.isoformat() if user.subscription_expires_at else None
|
||||
)
|
||||
new_data["subscription_expires_at"] = update_data.subscription_expires_at.isoformat()
|
||||
update_fields["subscription_expires_at"] = update_data.subscription_expires_at
|
||||
|
||||
if update_data.scope_level is not None:
|
||||
old_data["scope_level"] = user.scope_level
|
||||
new_data["scope_level"] = update_data.scope_level
|
||||
update_fields["scope_level"] = update_data.scope_level
|
||||
|
||||
if update_data.scope_id is not None:
|
||||
old_data["scope_id"] = user.scope_id
|
||||
new_data["scope_id"] = update_data.scope_id
|
||||
update_fields["scope_id"] = update_data.scope_id
|
||||
|
||||
if update_data.role_id is not None:
|
||||
old_data["role_id"] = user.role_id
|
||||
new_data["role_id"] = update_data.role_id
|
||||
update_fields["role_id"] = update_data.role_id
|
||||
|
||||
if update_data.custom_permissions is not None:
|
||||
old_data["custom_permissions"] = user.custom_permissions
|
||||
new_data["custom_permissions"] = update_data.custom_permissions
|
||||
update_fields["custom_permissions"] = update_data.custom_permissions
|
||||
|
||||
# ── 5. Person mezők frissítése ──
|
||||
person_updated = False
|
||||
if update_data.person is not None and user.person is not None:
|
||||
person = user.person
|
||||
person_update_fields: Dict[str, Any] = {}
|
||||
|
||||
if update_data.person.last_name is not None:
|
||||
old_data["person.last_name"] = person.last_name
|
||||
new_data["person.last_name"] = update_data.person.last_name
|
||||
person_update_fields["last_name"] = update_data.person.last_name
|
||||
|
||||
if update_data.person.first_name is not None:
|
||||
old_data["person.first_name"] = person.first_name
|
||||
new_data["person.first_name"] = update_data.person.first_name
|
||||
person_update_fields["first_name"] = update_data.person.first_name
|
||||
|
||||
if update_data.person.phone is not None:
|
||||
old_data["person.phone"] = person.phone
|
||||
new_data["person.phone"] = update_data.person.phone
|
||||
person_update_fields["phone"] = update_data.person.phone
|
||||
|
||||
if update_data.person.mothers_last_name is not None:
|
||||
old_data["person.mothers_last_name"] = person.mothers_last_name
|
||||
new_data["person.mothers_last_name"] = update_data.person.mothers_last_name
|
||||
person_update_fields["mothers_last_name"] = update_data.person.mothers_last_name
|
||||
|
||||
if update_data.person.mothers_first_name is not None:
|
||||
old_data["person.mothers_first_name"] = person.mothers_first_name
|
||||
new_data["person.mothers_first_name"] = update_data.person.mothers_first_name
|
||||
person_update_fields["mothers_first_name"] = update_data.person.mothers_first_name
|
||||
|
||||
if update_data.person.birth_place is not None:
|
||||
old_data["person.birth_place"] = person.birth_place
|
||||
new_data["person.birth_place"] = update_data.person.birth_place
|
||||
person_update_fields["birth_place"] = update_data.person.birth_place
|
||||
|
||||
if update_data.person.birth_date is not None:
|
||||
old_data["person.birth_date"] = (
|
||||
person.birth_date.isoformat() if person.birth_date else None
|
||||
)
|
||||
new_data["person.birth_date"] = update_data.person.birth_date.isoformat()
|
||||
person_update_fields["birth_date"] = update_data.person.birth_date
|
||||
|
||||
if update_data.person.identity_docs is not None:
|
||||
old_data["person.identity_docs"] = person.identity_docs
|
||||
new_data["person.identity_docs"] = update_data.person.identity_docs
|
||||
person_update_fields["identity_docs"] = update_data.person.identity_docs
|
||||
|
||||
if person_update_fields:
|
||||
for field, value in person_update_fields.items():
|
||||
setattr(person, field, value)
|
||||
person.updated_at = datetime.now(timezone.utc)
|
||||
person_updated = True
|
||||
|
||||
# ── 6. Ha nincs változás, dobjunk hibát ──
|
||||
if not update_fields and not person_updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nincs módosítandó mező.",
|
||||
)
|
||||
|
||||
# ── 7. Végrehajtjuk a frissítést ──
|
||||
if update_fields:
|
||||
stmt_update = (
|
||||
sa_update(User)
|
||||
.where(User.id == user_id)
|
||||
.values(**update_fields)
|
||||
)
|
||||
await db.execute(stmt_update)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# ── 8. Audit log ──
|
||||
try:
|
||||
audit_entry = AuditLog(
|
||||
user_id=current_user.id,
|
||||
severity=LogSeverity.info,
|
||||
action="ADMIN_USER_UPDATE",
|
||||
target_type="user",
|
||||
target_id=str(user_id),
|
||||
old_data=old_data if old_data else None,
|
||||
new_data=new_data if new_data else None,
|
||||
)
|
||||
db.add(audit_entry)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write audit log for user update {user_id}: {e}")
|
||||
await db.rollback()
|
||||
|
||||
# ── 9. Visszaadjuk a frissített adatokat ──
|
||||
# Újratöltjük a frissített adatokat
|
||||
stmt_refresh = (
|
||||
select(User)
|
||||
.where(User.id == user_id)
|
||||
.options(
|
||||
joinedload(User.person)
|
||||
.joinedload(Person.address)
|
||||
.joinedload(Address.postal_code)
|
||||
)
|
||||
)
|
||||
result_refresh = await db.execute(stmt_refresh)
|
||||
updated_user = result_refresh.unique().scalar_one_or_none()
|
||||
|
||||
from app.api.v1.endpoints.users import _build_user_response
|
||||
|
||||
response_data = await _build_user_response(updated_user, db=db)
|
||||
# Kibővítjük a hiányzó mezőkkel, amik a User modellen léteznek,
|
||||
# de a _build_user_response dict-ből és a UserResponse sémából hiányoznak
|
||||
response_data["is_vip"] = updated_user.is_vip
|
||||
response_data["preferred_currency"] = updated_user.preferred_currency
|
||||
response_data["custom_permissions"] = updated_user.custom_permissions
|
||||
response_data["subscription_expires_at"] = (
|
||||
updated_user.subscription_expires_at.isoformat() if updated_user.subscription_expires_at else None
|
||||
)
|
||||
response_data["scope_level"] = updated_user.scope_level or "individual"
|
||||
response_data["scope_id"] = str(updated_user.scope_id) if updated_user.scope_id else None
|
||||
return response_data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Memberships Endpoint
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class UserMembershipResponse(BaseModel):
|
||||
"""Egy szervezeti tagság adatai a felhasználó nézetében."""
|
||||
id: int
|
||||
organization_id: int
|
||||
organization_name: str = Field(..., description="Szervezet neve")
|
||||
organization_display_name: Optional[str] = Field(default=None, description="Szervezet megjelenítési neve")
|
||||
role: str = Field(..., description="Szerepkör a szervezetben")
|
||||
status: str = Field(default="active", description="Tagság státusza")
|
||||
is_verified: bool = Field(default=False, description="Ellenőrzött tagság")
|
||||
is_permanent: bool = Field(default=False, description="Állandó tagság")
|
||||
joined_at: Optional[str] = Field(default=None, description="Csatlakozás dátuma")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}/memberships",
|
||||
response_model=List[UserMembershipResponse],
|
||||
summary="Felhasználó szervezeti tagságai",
|
||||
description=(
|
||||
"Visszaadja egy felhasználó összes szervezeti tagságát. "
|
||||
"Csak admin/staff jogosultsággal érhető el."
|
||||
),
|
||||
)
|
||||
async def get_user_memberships(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("user:view")),
|
||||
) -> List[UserMembershipResponse]:
|
||||
"""
|
||||
GET /admin/users/{user_id}/memberships
|
||||
|
||||
Lekérdezi a felhasználó összes szervezeti tagságát.
|
||||
"""
|
||||
stmt = (
|
||||
select(OrganizationMember)
|
||||
.where(OrganizationMember.user_id == user_id)
|
||||
.options(joinedload(OrganizationMember.organization))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
memberships = result.unique().scalars().all()
|
||||
|
||||
membership_list = []
|
||||
for m in memberships:
|
||||
membership_list.append(UserMembershipResponse(
|
||||
id=m.id,
|
||||
organization_id=m.organization_id,
|
||||
organization_name=m.organization.name if m.organization else "Ismeretlen",
|
||||
organization_display_name=m.organization.display_name if m.organization else None,
|
||||
role=m.role,
|
||||
status=m.status,
|
||||
is_verified=m.is_verified,
|
||||
is_permanent=m.is_permanent,
|
||||
joined_at=m.joined_at.isoformat() if m.joined_at else None,
|
||||
))
|
||||
|
||||
return membership_list
|
||||
@@ -46,9 +46,10 @@ class PricingModel(BaseModel):
|
||||
|
||||
class AllowancesModel(BaseModel):
|
||||
"""Korlátok és keretek a csomagban."""
|
||||
max_vehicles: int = Field(..., ge=0, description="Maximális járműszám")
|
||||
max_garages: int = Field(..., ge=0, description="Maximális garázsok száma")
|
||||
monthly_free_credits: int = Field(..., ge=0, description="Havi ingyenes kreditek száma")
|
||||
max_vehicles: int = Field(default=0, ge=0, description="Maximális járműszám")
|
||||
max_garages: int = Field(default=0, ge=0, description="Maximális garázsok száma")
|
||||
max_users: int = Field(default=0, ge=0, description="Maximális dolgozók/felhasználók száma (0 = nincs korlátozva)")
|
||||
monthly_free_credits: int = Field(default=0, ge=0, description="Havi ingyenes kreditek száma")
|
||||
|
||||
|
||||
class DurationModel(BaseModel):
|
||||
@@ -100,7 +101,7 @@ class SubscriptionRulesModel(BaseModel):
|
||||
ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days)
|
||||
marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)
|
||||
"""
|
||||
type: str = Field(..., pattern=r"^(private|corporate)$", description="Csomag típusa")
|
||||
type: Optional[str] = Field(default=None, pattern=r"^(private|corporate|addon)$", description="Csomag típusa (private/corporate/addon)")
|
||||
display_name: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Emberi olvasásra szánt megjelenítési név (pl. 'Privát Ingyenes')",
|
||||
@@ -141,9 +142,11 @@ class SubscriptionRulesModel(BaseModel):
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def validate_type(cls, v: str) -> str:
|
||||
if v.lower() not in ("private", "corporate"):
|
||||
raise ValueError("A 'type' mező csak 'private' vagy 'corporate' lehet.")
|
||||
def validate_type(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return v
|
||||
if v.lower() not in ("private", "corporate", "addon"):
|
||||
raise ValueError("A 'type' mező csak 'private', 'corporate' vagy 'addon' lehet.")
|
||||
return v.lower()
|
||||
|
||||
|
||||
@@ -184,6 +187,7 @@ class PublicSubscriptionTierResponse(BaseModel):
|
||||
class SubscriptionTierCreate(BaseModel):
|
||||
"""Új előfizetési csomag létrehozása (POST /)."""
|
||||
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
|
||||
type: Optional[str] = Field(default="base", description="Csomag típusa: 'base' (alap) vagy 'addon' (kiegészítő)")
|
||||
rules: SubscriptionRulesModel
|
||||
is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e")
|
||||
tier_level: int = Field(default=0, ge=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise")
|
||||
@@ -202,6 +206,7 @@ class SubscriptionTierCreate(BaseModel):
|
||||
class SubscriptionTierUpdate(BaseModel):
|
||||
"""Meglévő előfizetési csomag részleges frissítése (PATCH /{tier_id})."""
|
||||
name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév")
|
||||
type: Optional[str] = Field(default=None, description="Csomag típusa: 'base' vagy 'addon'")
|
||||
rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB")
|
||||
is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés")
|
||||
tier_level: Optional[int] = Field(default=None, ge=0, description="Entitlement hierarchy level")
|
||||
|
||||
@@ -670,9 +670,13 @@ class AssetService:
|
||||
A művelet:
|
||||
1. Frissíti a current_mileage értékét a megadott final_mileage-ra
|
||||
2. Állítja: status = 'archived'
|
||||
3. Nullázza az owner_person_id és owner_org_id mezőket
|
||||
3. Nullázza az owner_person_id, owner_org_id és current_organization_id mezőket
|
||||
4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
|
||||
5. Naplózza a biztonsági auditba
|
||||
|
||||
P0 CRITICAL FIX: current_organization_id is also cleared so the vehicle
|
||||
no longer counts toward the org's active vehicle quota. The previous
|
||||
value is preserved in archive_info for audit/historical purposes.
|
||||
"""
|
||||
from app.models.identity import User
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -712,14 +716,19 @@ class AssetService:
|
||||
'archived_at': now_iso,
|
||||
'previous_owner_person_id': asset.owner_person_id,
|
||||
'previous_owner_org_id': asset.owner_org_id,
|
||||
'previous_current_organization_id': asset.current_organization_id,
|
||||
'final_mileage': final_mileage,
|
||||
}
|
||||
|
||||
# 5. Frissítjük a jármű adatait
|
||||
# P0 CRITICAL FIX: Clear current_organization_id so the vehicle no longer
|
||||
# counts toward the org's active vehicle quota. The previous value is
|
||||
# preserved in archive_info above for historical audit purposes.
|
||||
asset.current_mileage = final_mileage
|
||||
asset.status = "archived"
|
||||
asset.owner_person_id = None
|
||||
asset.owner_org_id = None
|
||||
asset.current_organization_id = None
|
||||
asset.individual_equipment = equipment
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
197
backend/tests/test_admin_user_patch.py
Normal file
197
backend/tests/test_admin_user_patch.py
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for PATCH /admin/users/{user_id} endpoint.
|
||||
Uses form-data for OAuth2 login, then tests the admin user update endpoint.
|
||||
"""
|
||||
import httpx
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
|
||||
BASE_URL = "http://sf_api:8000/api/v1"
|
||||
|
||||
async def test_admin_user_patch():
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
# Step 1: Login as admin using form data (OAuth2PasswordRequestForm)
|
||||
print("=" * 60)
|
||||
print("STEP 1: Login as admin")
|
||||
print("=" * 60)
|
||||
|
||||
login_data = {
|
||||
"username": "admin@profibot.hu",
|
||||
"password": "Admin123!",
|
||||
"remember_me": "true"
|
||||
}
|
||||
|
||||
r = await client.post("/auth/login", data=login_data)
|
||||
print(f"Status: {r.status_code}")
|
||||
|
||||
if r.status_code != 200:
|
||||
print(f"Login failed: {r.text}")
|
||||
return False
|
||||
|
||||
token_data = r.json()
|
||||
access_token = token_data.get("access_token")
|
||||
print(f"Token received: {access_token[:50]}...")
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
# Step 2: Try to find a non-superadmin user
|
||||
print("\n" + "=" * 60)
|
||||
print("STEP 2: Find a non-superadmin user")
|
||||
print("=" * 60)
|
||||
|
||||
# Try user IDs 2, 3, 4, etc. until we find a regular user
|
||||
test_user_id = None
|
||||
for uid in [2, 3, 4, 5]:
|
||||
r = await client.get(f"/admin/users/{uid}", headers=headers)
|
||||
if r.status_code == 200:
|
||||
user_data = r.json()
|
||||
role = user_data.get("role", "")
|
||||
print(f" User ID={uid}: email={user_data.get('email')}, role={role}")
|
||||
if role != "superadmin":
|
||||
test_user_id = uid
|
||||
break
|
||||
else:
|
||||
print(f" User ID={uid}: Status={r.status_code}")
|
||||
|
||||
if test_user_id is None:
|
||||
print("No non-superadmin user found. Using user ID 2 as fallback.")
|
||||
test_user_id = 2
|
||||
|
||||
# Get the user details
|
||||
r = await client.get(f"/admin/users/{test_user_id}", headers=headers)
|
||||
if r.status_code != 200:
|
||||
print(f"Failed to get user {test_user_id}: {r.text}")
|
||||
return False
|
||||
|
||||
user_data = r.json()
|
||||
user_id = user_data.get("id")
|
||||
user_email = user_data.get("email")
|
||||
user_role = user_data.get("role")
|
||||
print(f"\nUsing test user: ID={user_id}, Email={user_email}, Role={user_role}")
|
||||
|
||||
# Step 3: Get user details before update
|
||||
print("\n" + "=" * 60)
|
||||
print(f"STEP 3: User details (before update)")
|
||||
print("=" * 60)
|
||||
|
||||
print(f" is_vip: {user_data.get('is_vip')}")
|
||||
print(f" preferred_language: {user_data.get('preferred_language')}")
|
||||
print(f" is_active: {user_data.get('is_active')}")
|
||||
print(f" region_code: {user_data.get('region_code')}")
|
||||
print(f" preferred_currency: {user_data.get('preferred_currency')}")
|
||||
print(f" subscription_plan: {user_data.get('subscription_plan')}")
|
||||
|
||||
# Step 4: PATCH the user
|
||||
print("\n" + "=" * 60)
|
||||
print(f"STEP 4: PATCH user {user_id} - update fields")
|
||||
print("=" * 60)
|
||||
|
||||
patch_data = {
|
||||
"is_vip": True,
|
||||
"preferred_language": "en",
|
||||
"region_code": "HU",
|
||||
"preferred_currency": "HUF",
|
||||
}
|
||||
|
||||
r = await client.patch(f"/admin/users/{user_id}", json=patch_data, headers=headers)
|
||||
print(f"Status: {r.status_code}")
|
||||
|
||||
if r.status_code != 200:
|
||||
print(f"PATCH failed: {r.text}")
|
||||
return False
|
||||
|
||||
updated_user = r.json()
|
||||
print(f"User after update:")
|
||||
print(f" is_vip: {updated_user.get('is_vip')}")
|
||||
print(f" preferred_language: {updated_user.get('preferred_language')}")
|
||||
print(f" region_code: {updated_user.get('region_code')}")
|
||||
print(f" preferred_currency: {updated_user.get('preferred_currency')}")
|
||||
|
||||
# Verify the changes
|
||||
assert updated_user.get("is_vip") == True, "is_vip not updated"
|
||||
assert updated_user.get("preferred_language") == "en", "preferred_language not updated"
|
||||
assert updated_user.get("region_code") == "HU", "region_code not updated"
|
||||
assert updated_user.get("preferred_currency") == "HUF", "preferred_currency not updated"
|
||||
|
||||
print("\n✅ All basic field updates verified!")
|
||||
|
||||
# Step 5: Test person sub-object update
|
||||
print("\n" + "=" * 60)
|
||||
print(f"STEP 5: PATCH user {user_id} - update person fields")
|
||||
print("=" * 60)
|
||||
|
||||
patch_person_data = {
|
||||
"person": {
|
||||
"last_name": "Updated",
|
||||
"first_name": "User",
|
||||
"phone": "+36123456789"
|
||||
}
|
||||
}
|
||||
|
||||
r = await client.patch(f"/admin/users/{user_id}", json=patch_person_data, headers=headers)
|
||||
print(f"Status: {r.status_code}")
|
||||
|
||||
if r.status_code != 200:
|
||||
print(f"PATCH person failed: {r.text}")
|
||||
else:
|
||||
updated_user = r.json()
|
||||
person = updated_user.get("person", {})
|
||||
print(f"Person after update:")
|
||||
print(f" last_name: {person.get('last_name')}")
|
||||
print(f" first_name: {person.get('first_name')}")
|
||||
print(f" phone: {person.get('phone')}")
|
||||
|
||||
if person.get("last_name") == "Updated":
|
||||
print("✅ Person fields updated successfully!")
|
||||
else:
|
||||
print("⚠️ Person fields may not have been updated (might not have a person record)")
|
||||
|
||||
# Step 6: Test error cases
|
||||
print("\n" + "=" * 60)
|
||||
print("STEP 6: Test error cases")
|
||||
print("=" * 60)
|
||||
|
||||
# 6a: Non-existent user
|
||||
r = await client.patch("/admin/users/99999", json={"is_vip": True}, headers=headers)
|
||||
print(f"6a - Non-existent user (99999): Status={r.status_code}")
|
||||
if r.status_code == 404:
|
||||
print("✅ Correctly returned 404")
|
||||
else:
|
||||
print(f"⚠️ Unexpected: {r.text}")
|
||||
|
||||
# 6b: Empty update
|
||||
r = await client.patch(f"/admin/users/{user_id}", json={}, headers=headers)
|
||||
print(f"6b - Empty update: Status={r.status_code}")
|
||||
if r.status_code == 400:
|
||||
print("✅ Correctly returned 400 for empty update")
|
||||
elif r.status_code == 200:
|
||||
print("⚠️ Empty update returned 200 (no changes made)")
|
||||
else:
|
||||
print(f"⚠️ Unexpected: {r.text}")
|
||||
|
||||
# 6c: Invalid field
|
||||
r = await client.patch(f"/admin/users/{user_id}", json={"nonexistent_field": "test"}, headers=headers)
|
||||
print(f"6c - Invalid field: Status={r.status_code}")
|
||||
if r.status_code == 422:
|
||||
print("✅ Correctly returned 422 for invalid field")
|
||||
else:
|
||||
print(f"⚠️ Unexpected: {r.text}")
|
||||
|
||||
# 6d: Try to modify superadmin (should be blocked)
|
||||
r = await client.patch("/admin/users/1", json={"is_vip": True}, headers=headers)
|
||||
print(f"6d - Modify superadmin: Status={r.status_code}")
|
||||
if r.status_code == 403:
|
||||
print("✅ Correctly blocked superadmin modification")
|
||||
else:
|
||||
print(f"⚠️ Unexpected: {r.text}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("ALL TESTS COMPLETED")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = asyncio.run(test_admin_user_patch())
|
||||
sys.exit(0 if success else 1)
|
||||
445
backend/tests/test_extract_limit_standalone.py
Normal file
445
backend/tests/test_extract_limit_standalone.py
Normal file
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
P0 DATA STRUCTURAL AUDIT: _extract_limit() Standalone Test Script
|
||||
|
||||
This script simulates the exact _extract_limit() function and feeds it
|
||||
the ACTUAL JSON data from the database to verify if the limit extraction
|
||||
works correctly for corporate tiers.
|
||||
|
||||
Author: System Architect
|
||||
Date: 2026-06-28
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXACT copy of _extract_limit() from admin_organizations.py:397
|
||||
# =============================================================================
|
||||
|
||||
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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ACTUAL database JSON data (retrieved via PostgreSQL MCP query on 2026-06-28)
|
||||
# =============================================================================
|
||||
|
||||
# corp_premium_plus_v1 (id=17) — exact JSON as stored in system.subscription_tiers.rules
|
||||
CORP_PREMIUM_PLUS_V1_RAW = """{
|
||||
"type": "corporate",
|
||||
"pricing": {
|
||||
"currency": "EUR",
|
||||
"credit_price": 6000,
|
||||
"yearly_price": 599.99,
|
||||
"monthly_price": 59.99
|
||||
},
|
||||
"duration": {
|
||||
"days": 30,
|
||||
"allow_stacking": true
|
||||
},
|
||||
"ad_policy": {
|
||||
"show_ads": false,
|
||||
"ad_free_grace_days": 0,
|
||||
"max_daily_impressions": null
|
||||
},
|
||||
"affiliate": {
|
||||
"referral_bonus_credits": 250,
|
||||
"commission_rate_percent": 20
|
||||
},
|
||||
"lifecycle": {
|
||||
"is_public": true
|
||||
},
|
||||
"marketing": {
|
||||
"badge": "Plus",
|
||||
"subtitle": "Nagy flották prémium menedzsmentje.",
|
||||
"highlight_color": "#8B5CF6"
|
||||
},
|
||||
"allowances": {
|
||||
"max_garages": 10,
|
||||
"max_vehicles": 50,
|
||||
"monthly_free_credits": 800
|
||||
},
|
||||
"display_name": "Céges Prémium Plus",
|
||||
"entitlements": [
|
||||
"SRV_DATA_EXPORT",
|
||||
"SRV_AI_UPLOAD",
|
||||
"SRV_ACCOUNTING_SYNC"
|
||||
],
|
||||
"pricing_zones": {
|
||||
"HU": {
|
||||
"currency": "HUF",
|
||||
"credit_price": 6000,
|
||||
"yearly_price": 239990,
|
||||
"monthly_price": 23990
|
||||
},
|
||||
"US": {
|
||||
"currency": "USD",
|
||||
"credit_price": 6000,
|
||||
"yearly_price": 699.99,
|
||||
"monthly_price": 69.99
|
||||
},
|
||||
"DEFAULT": {
|
||||
"currency": "EUR",
|
||||
"credit_price": 6000,
|
||||
"yearly_price": 599.99,
|
||||
"monthly_price": 59.99
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
# corp_premium_v1 (id=16) — exact JSON as stored
|
||||
CORP_PREMIUM_V1_RAW = """{
|
||||
"type": "corporate",
|
||||
"pricing": {
|
||||
"currency": "EUR",
|
||||
"credit_price": 3000,
|
||||
"yearly_price": 299.99,
|
||||
"monthly_price": 29.99
|
||||
},
|
||||
"duration": {
|
||||
"days": 30,
|
||||
"allow_stacking": true
|
||||
},
|
||||
"ad_policy": {
|
||||
"show_ads": false,
|
||||
"ad_free_grace_days": 0,
|
||||
"max_daily_impressions": null
|
||||
},
|
||||
"affiliate": {
|
||||
"referral_bonus_credits": 100,
|
||||
"commission_rate_percent": 15
|
||||
},
|
||||
"lifecycle": {
|
||||
"is_public": true
|
||||
},
|
||||
"marketing": {
|
||||
"badge": "Prémium",
|
||||
"subtitle": "Közepes flották számára tervezve.",
|
||||
"highlight_color": "#3B82F6"
|
||||
},
|
||||
"allowances": {
|
||||
"max_garages": 3,
|
||||
"max_vehicles": 20,
|
||||
"monthly_free_credits": 300
|
||||
},
|
||||
"display_name": "Céges Prémium",
|
||||
"entitlements": [
|
||||
"SRV_DATA_EXPORT",
|
||||
"SRV_AI_UPLOAD"
|
||||
],
|
||||
"pricing_zones": {
|
||||
"HU": {
|
||||
"currency": "HUF",
|
||||
"credit_price": 3000,
|
||||
"yearly_price": 119990,
|
||||
"monthly_price": 11990
|
||||
},
|
||||
"US": {
|
||||
"currency": "USD",
|
||||
"credit_price": 3000,
|
||||
"yearly_price": 349.99,
|
||||
"monthly_price": 34.99
|
||||
},
|
||||
"DEFAULT": {
|
||||
"currency": "EUR",
|
||||
"credit_price": 3000,
|
||||
"yearly_price": 299.99,
|
||||
"monthly_price": 29.99
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
# private_pro_v1 (id=14) — exact JSON as stored
|
||||
PRIVATE_PRO_V1_RAW = """{
|
||||
"type": "private",
|
||||
"lifecycle": {
|
||||
"is_public": true,
|
||||
"available_until": null
|
||||
},
|
||||
"marketing": {
|
||||
"badge": null,
|
||||
"subtitle": null
|
||||
},
|
||||
"allowances": {
|
||||
"max_garages": 1,
|
||||
"max_vehicles": 3,
|
||||
"monthly_free_credits": 0
|
||||
},
|
||||
"pricing_zones": {
|
||||
"HU": {
|
||||
"currency": "HUF",
|
||||
"credit_price": 25000,
|
||||
"yearly_price": 9990.0,
|
||||
"monthly_price": 990.0
|
||||
},
|
||||
"US": {
|
||||
"currency": "USD",
|
||||
"credit_price": 25000,
|
||||
"yearly_price": 35.9,
|
||||
"monthly_price": 3.5
|
||||
},
|
||||
"DEFAULT": {
|
||||
"currency": "EUR",
|
||||
"credit_price": 25000,
|
||||
"yearly_price": 29.9,
|
||||
"monthly_price": 2.99
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
# feature_capabilities data (as stored)
|
||||
CORP_PREMIUM_PLUS_V1_FC_RAW = "{}"
|
||||
CORP_PREMIUM_V1_FC_RAW = "{}"
|
||||
PRIVATE_PRO_V1_FC_RAW = """{"export_data": false, "advanced_reports": true, "max_cost_category_depth": 3}"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JSON double-encoding simulation
|
||||
# =============================================================================
|
||||
DOUBLE_ENCODED_RAW = json.dumps(CORP_PREMIUM_PLUS_V1_RAW)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test harness
|
||||
# =============================================================================
|
||||
|
||||
def print_separator(title: str):
|
||||
print(f"\n{'='*80}")
|
||||
print(f" {title}")
|
||||
print(f"{'='*80}")
|
||||
|
||||
|
||||
def test_single_tier(
|
||||
tier_name: str,
|
||||
rules_raw: str,
|
||||
fc_raw: str,
|
||||
vehicle_keys: list,
|
||||
branch_keys: list,
|
||||
user_keys: list,
|
||||
expected_vehicles: int,
|
||||
expected_branches: int,
|
||||
expected_users: int,
|
||||
):
|
||||
"""Test _extract_limit for a single tier, simulating the exact endpoint logic."""
|
||||
print(f"\n ┌─ Tier: {tier_name}")
|
||||
|
||||
rules = json.loads(rules_raw)
|
||||
fc = json.loads(fc_raw)
|
||||
|
||||
print(f" ├─ rules type: {type(rules).__name__}")
|
||||
print(f" ├─ rules keys: {list(rules.keys())}")
|
||||
print(f" ├─ 'allowances' key present: {'allowances' in rules}")
|
||||
if 'allowances' in rules:
|
||||
print(f" ├─ allowances content: {json.dumps(rules['allowances'], indent=4)}")
|
||||
|
||||
base_vehicles = _extract_limit(
|
||||
rules, vehicle_keys,
|
||||
_extract_limit(fc, vehicle_keys, 1),
|
||||
)
|
||||
base_branches = _extract_limit(
|
||||
rules, branch_keys,
|
||||
_extract_limit(fc, branch_keys, 0),
|
||||
)
|
||||
base_users = _extract_limit(
|
||||
rules, user_keys,
|
||||
_extract_limit(fc, user_keys, 0),
|
||||
)
|
||||
|
||||
print(f" ├─ RESULT: vehicles={base_vehicles} (expected={expected_vehicles})")
|
||||
print(f" ├─ RESULT: branches={base_branches} (expected={expected_branches})")
|
||||
print(f" └─ RESULT: users={base_users} (expected={expected_users})")
|
||||
|
||||
status = "✅ PASS" if (base_vehicles == expected_vehicles and
|
||||
base_branches == expected_branches and
|
||||
base_users == expected_users) else "❌ FAIL"
|
||||
print(f" {status}")
|
||||
return status
|
||||
|
||||
|
||||
def test_double_encoding_scenario():
|
||||
"""Test what happens if the JSON is double-encoded."""
|
||||
print_separator("SIMULATION: Double-encoded JSON (a known anti-pattern)")
|
||||
|
||||
double_encoded = DOUBLE_ENCODED_RAW
|
||||
print(f"\n Double-encoded value preview: {double_encoded[:80]}...")
|
||||
print(f" Double-encoded type: {type(double_encoded).__name__}")
|
||||
|
||||
parsed = json.loads(double_encoded)
|
||||
print(f" After json.loads(): type={type(parsed).__name__}")
|
||||
print(f" After json.loads(): is dict? {isinstance(parsed, dict)}")
|
||||
print(f" After json.loads(): is str? {isinstance(parsed, str)}")
|
||||
|
||||
if isinstance(parsed, str):
|
||||
print(f"\n ❌ DOUBLE-ENCODING DETECTED!")
|
||||
print(f" _extract_limit receives a STRING, not a dict!")
|
||||
print(f" First check: 'if not rules or not isinstance(rules, dict):'")
|
||||
print(f" → Returns default=0 immediately!")
|
||||
result = _extract_limit(parsed, ["max_vehicles", "max_assets"], 0)
|
||||
print(f" _extract_limit(string, ...) = {result}")
|
||||
else:
|
||||
print(f"\n ✅ No double-encoding. Data is properly deserialized as dict.")
|
||||
|
||||
|
||||
def test_edge_cases():
|
||||
"""Test edge cases and potential failure modes."""
|
||||
print_separator("EDGE CASE TESTS")
|
||||
|
||||
result = _extract_limit({}, ["max_vehicles"], 5)
|
||||
print(f" Empty dict -> {result} (expected: 5) {'✅' if result == 5 else '❌'}")
|
||||
|
||||
result = _extract_limit(None, ["max_vehicles"], 5)
|
||||
print(f" None -> {result} (expected: 5) {'✅' if result == 5 else '❌'}")
|
||||
|
||||
result = _extract_limit("not a dict", ["max_vehicles"], 5)
|
||||
print(f" String input -> {result} (expected: 5) {'✅' if result == 5 else '❌'}")
|
||||
|
||||
result = _extract_limit({"max_vehicles": 100}, ["max_vehicles", "max_assets"], 0)
|
||||
print(f" Direct key match -> {result} (expected: 100) {'✅' if result == 100 else '❌'}")
|
||||
|
||||
result = _extract_limit({"allowances": {"max_vehicles": 50}}, ["max_vehicles", "max_assets"], 0)
|
||||
print(f" allowances['max_vehicles'] -> {result} (expected: 50) {'✅' if result == 50 else '❌'}")
|
||||
|
||||
result = _extract_limit({"limits": {"max_vehicles": 25}}, ["max_vehicles", "max_assets"], 0)
|
||||
print(f" limits['max_vehicles'] -> {result} (expected: 25) {'✅' if result == 25 else '❌'}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
print_separator("P0 DATA STRUCTURAL AUDIT: _extract_limit() Standalone Test")
|
||||
print(" Date: 2026-06-28")
|
||||
print(" Source: system.subscription_tiers (PostgreSQL JSONB)")
|
||||
|
||||
# --- Test 1: corp_premium_plus_v1 ---
|
||||
print_separator("TEST 1: corp_premium_plus_v1 (id=17)")
|
||||
print(" Expected: vehicles=50, branches=10, users=?")
|
||||
print(" Note: 'max_users'/'max_members' keys NOT present in rules JSON.")
|
||||
print(" The function will fall back to feature_capabilities (empty dict {}),")
|
||||
print(" then to the nested default (0).")
|
||||
|
||||
test_single_tier(
|
||||
tier_name="corp_premium_plus_v1",
|
||||
rules_raw=CORP_PREMIUM_PLUS_V1_RAW,
|
||||
fc_raw=CORP_PREMIUM_PLUS_V1_FC_RAW,
|
||||
vehicle_keys=["max_vehicles", "max_assets"],
|
||||
branch_keys=["max_branches", "max_garages"],
|
||||
user_keys=["max_users", "max_members"],
|
||||
expected_vehicles=50,
|
||||
expected_branches=10,
|
||||
expected_users=0,
|
||||
)
|
||||
|
||||
# --- Test 2: corp_premium_v1 ---
|
||||
print_separator("TEST 2: corp_premium_v1 (id=16)")
|
||||
test_single_tier(
|
||||
tier_name="corp_premium_v1",
|
||||
rules_raw=CORP_PREMIUM_V1_RAW,
|
||||
fc_raw=CORP_PREMIUM_V1_FC_RAW,
|
||||
vehicle_keys=["max_vehicles", "max_assets"],
|
||||
branch_keys=["max_branches", "max_garages"],
|
||||
user_keys=["max_users", "max_members"],
|
||||
expected_vehicles=20,
|
||||
expected_branches=3,
|
||||
expected_users=0,
|
||||
)
|
||||
|
||||
# --- Test 3: private_pro_v1 ---
|
||||
print_separator("TEST 3: private_pro_v1 (id=14)")
|
||||
test_single_tier(
|
||||
tier_name="private_pro_v1",
|
||||
rules_raw=PRIVATE_PRO_V1_RAW,
|
||||
fc_raw=PRIVATE_PRO_V1_FC_RAW,
|
||||
vehicle_keys=["max_vehicles", "max_assets"],
|
||||
branch_keys=["max_branches", "max_garages"],
|
||||
user_keys=["max_users", "max_members"],
|
||||
expected_vehicles=3,
|
||||
expected_branches=1,
|
||||
expected_users=0,
|
||||
)
|
||||
|
||||
# --- Double-encoding simulation ---
|
||||
test_double_encoding_scenario()
|
||||
|
||||
# --- Edge cases ---
|
||||
test_edge_cases()
|
||||
|
||||
# --- Summary ---
|
||||
print_separator("SUMMARY")
|
||||
print("""
|
||||
KEY FINDINGS FROM DB AUDIT:
|
||||
|
||||
1. All corporate tiers (corp_premium_plus_v1, corp_premium_v1)
|
||||
store their limits under rules['allowances'] nested object.
|
||||
|
||||
2. The _extract_limit() function correctly searches at 3 levels:
|
||||
Level 1: Direct keys in root (rules['max_vehicles'])
|
||||
Level 2: Nested under 'allowances' (rules['allowances']['max_vehicles'])
|
||||
Level 3: Nested under 'limits' (rules['limits']['max_vehicles'])
|
||||
|
||||
3. For corp_premium_plus_v1: allowances.max_vehicles = 50
|
||||
Function should return 50, not 0.
|
||||
|
||||
4. ROOT CAUSE SUSPECTED: If _extract_limit returns 0 at runtime,
|
||||
the issue is likely:
|
||||
a) The 'rules' field is double-encoded (string inside JSONB)
|
||||
b) The wrong code path is being executed (e.g., no active_subs found)
|
||||
c) The rules dict is empty {} due to data migration issue
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user