admin_users_personels

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

View File

@@ -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"])

View File

@@ -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)

View File

@@ -13,7 +13,7 @@ Végpontok:
from __future__ import annotations
import logging
from datetime import datetime
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -28,6 +28,7 @@ from app.models.core_logic import SubscriptionTier, OrganizationSubscription
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.fleet.organization import ContactPerson
from app.models.identity import User, Person
from app.models.vehicle.history import AuditLog, LogSeverity
from app.schemas.organization import (
OrganizationMemberCreate,
OrganizationMemberUpdate,
@@ -45,8 +46,24 @@ router = APIRouter()
class OrgSubscriptionUpdate(BaseModel):
"""Előfizetés módosításának kérése."""
tier_id: int = Field(..., description="Az új SubscriptionTier ID-ja")
"""Előfizetés módosításának kérése.
Két üzemmód:
- Mode A (tier_id megadva): Teljes előfizetés váltás (új tier, új subscription sor).
- Mode B (tier_id = None): Csak az add-on (extra_allowances) frissítése a meglévő
aktív előfizetésen. Ekkor a tier_id, expires_at mezők figyelmen kívül maradnak.
P0 ARCHITECTURE SHIFT (Itemized Add-ons):
- Mode B most már ITEMIZÁLT: minden add-on típushoz külön OrganizationSubscription
sort hoz létre/frissít a finance.org_subscriptions táblában, saját valid_until-lal.
- A régi extra_vehicles/extra_branches/extra_users mezők továbbra is támogatottak
a frontend kompatibilitás miatt, de a belső logika itemizált sorokká alakítja őket.
"""
tier_id: Optional[int] = Field(
default=None,
description="Az új SubscriptionTier ID-ja. Ha None (Mode B), csak az add-on-ok "
"frissülnek a meglévő aktív előfizetésen."
)
expires_at: Optional[datetime] = Field(
default=None,
description="Egyedi lejárati dátum (pl. 2-5 éves B2B deal esetén). "
@@ -65,6 +82,22 @@ class OrgSubscriptionUpdate(BaseModel):
default=0, ge=0,
description="Extra dolgozó kvóta a csomag keretein felül (add-on)."
)
# ── P0 ITEMIZED ADD-ON: Individual expiration dates per add-on type ──
extra_vehicles_expires_at: Optional[datetime] = Field(
default=None,
description="Egyedi lejárati dátum az Extra Jármű add-on-hoz. "
"Dátum vagy datetime; a backend 23:59:59-re normalizálja."
)
extra_branches_expires_at: Optional[datetime] = Field(
default=None,
description="Egyedi lejárati dátum az Extra Telephely add-on-hoz. "
"Dátum vagy datetime; a backend 23:59:59-re normalizálja."
)
extra_users_expires_at: Optional[datetime] = Field(
default=None,
description="Egyedi lejárati dátum az Extra Dolgozó add-on-hoz. "
"Dátum vagy datetime; a backend 23:59:59-re normalizálja."
)
class OrgSubscriptionResponse(BaseModel):
@@ -126,10 +159,21 @@ class ContactPersonInfo(BaseModel):
is_primary: bool = False
class AddonInfo(BaseModel):
"""P0 ITEMIZED ADD-ON: Egy add-on sor adatai."""
type: str
quantity: int
subscription_id: Optional[int] = None
valid_until: Optional[str] = None
expires_at: Optional[str] = None
tier_name: Optional[str] = None
class SubscriptionSummary(BaseModel):
"""Előfizetés összefoglaló."""
tier_name: str = "Free/Fallback"
tier_level: int = 0
tier_id: Optional[int] = None
valid_from: Optional[str] = None
expires_at: Optional[str] = None
is_active: bool = True
@@ -140,11 +184,13 @@ class SubscriptionSummary(BaseModel):
user_limit: int = 0
# ── P0 ADD-ON UPGRADE: Extra allowances (add-ons) ──
extra_allowances: dict = {}
# ── P0 ITEMIZED ADD-ON: Individual add-on rows ──
addons: List[AddonInfo] = []
class BranchBrief(BaseModel):
"""Rövid telephely adatok a frontend számára."""
id: int
id: str
name: str
city: Optional[str] = None
is_active: bool = True
@@ -200,6 +246,21 @@ class GarageDetailsResponse(BaseModel):
members: List[OrganizationMemberResponse] = []
# ── P0 ADD-ON UPGRADE: Branches list for utilization display ──
branches: List[BranchBrief] = []
# ── P0 AUDIT TRAIL: Subscription history from audit.audit_logs ──
subscription_history: List["SubscriptionHistoryItem"] = []
class SubscriptionHistoryItem(BaseModel):
"""Egy előfizetés-módosítási esemény az audit naplóból."""
id: int
action: str
admin_user_id: Optional[int] = None
old_data: Optional[Dict[str, Any]] = None
new_data: Optional[Dict[str, Any]] = None
timestamp: Optional[str] = None
# Denormalized display fields
tier_name: Optional[str] = None
changes_summary: Optional[str] = None
# =============================================================================
@@ -447,6 +508,105 @@ def _extract_limit(
return default
# =============================================================================
# P0 ARCHITECTURE SHIFT: normalize_to_end_of_day() helper
# =============================================================================
def normalize_to_end_of_day(dt: Optional[datetime]) -> Optional[datetime]:
"""
P0 ARCHITECTURE SHIFT: Minden valid_until értéket nap végére (23:59:59) normalizál.
Ez biztosítja, hogy a lejárati dátumok konzisztensen viselkedjenek:
- Ha a user 2026-12-31-et ad meg, az 2026-12-31 23:59:59 UTC lesz.
- Ha a datetime már tartalmaz időt, azt felülírjuk 23:59:59-re.
- Ha None, visszatérünk None-nal.
Támogatja a datetime és date objektumokat is:
- datetime: közvetlenül normalizálja
- date: előbb datetime-vé alakítja (éjjel 00:00:00), majd normalizálja 23:59:59-re
Args:
dt: A normalizálandó datetime vagy date (lehet timezone-aware vagy naive).
Returns:
Optional[datetime]: A nap végére normalizált datetime (timezone-aware UTC),
vagy None ha a bemenet is None.
"""
if dt is None:
return None
# Ha date objektum, alakítsuk datetime-vé
if not isinstance(dt, datetime):
from datetime import date as date_type
if isinstance(dt, date_type):
dt = datetime(dt.year, dt.month, dt.day, tzinfo=timezone.utc)
# Ha naive, tegyük UTC-vé
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.replace(hour=23, minute=59, second=59, microsecond=0)
# =============================================================================
# P0 ARCHITECTURE SHIFT: _find_or_create_addon_tier() helper
# =============================================================================
ADDON_TIER_NAMES = {
"extra_vehicles": "Extra Vehicles",
"extra_branches": "Extra Branches",
"extra_users": "Extra Users",
}
async def _find_or_create_addon_tier(
db: AsyncSession,
addon_type: str,
) -> Optional[int]:
"""
P0 ARCHITECTURE SHIFT: Megkeresi vagy létrehozza az add-on SubscriptionTier-t.
Minden add-on típushoz ('extra_vehicles', 'extra_branches', 'extra_users')
léteznie kell egy dedikált SubscriptionTier-nek 'addon' típussal.
Ha nem létezik, automatikusan létrehozza.
Args:
db: Az adatbázis session.
addon_type: Az add-on típus neve (pl. 'extra_vehicles').
Returns:
Optional[int]: A SubscriptionTier ID-ja, vagy None ha nem sikerült.
"""
display_name = ADDON_TIER_NAMES.get(addon_type, addon_type.replace("_", " ").title())
# 1. Próbáljuk megkeresni a meglévő add-on tier-t
stmt = select(SubscriptionTier).where(
SubscriptionTier.type == "addon",
SubscriptionTier.name == addon_type,
)
result = await db.execute(stmt)
tier = result.scalar_one_or_none()
if tier:
return tier.id
# 2. Ha nem létezik, hozzuk létre
new_tier = SubscriptionTier(
name=addon_type,
type="addon",
tier_level=0,
is_custom=True,
rules={
"display_name": display_name,
"allowances": {f"max_{addon_type.replace('extra_', '')}": 0},
},
feature_capabilities={},
)
db.add(new_tier)
await db.flush()
logger.info(f"P0: Auto-created add-on tier '{addon_type}' (id={new_tier.id})")
return new_tier.id
# =============================================================================
# GET /{org_id}/details — Garázs részletes adatai (General Tab)
# =============================================================================
@@ -499,14 +659,22 @@ async def get_organization_details(
# 2. Aktív előfizetések lekérése (P0 ARCHITECTURE UNIFICATION: .all() instead of .limit(1))
# Több subscription is lehet (1 Base + N Add-on). Minden aktív sort lekérünk,
# majd aggregáljuk a limit értékeket.
now = datetime.utcnow()
# P0 CRITICAL FIX: Add-on subscriptions may have valid_until=NULL (indefinite),
# so we must use OR condition: valid_until >= now OR valid_until IS NULL.
# P0 CRITICAL FIX: Use timezone-aware UTC now() because valid_until is
# DateTime(timezone=True). Comparing naive datetime with timezone-aware
# timestamp causes PostgreSQL to return no results.
now = datetime.now(timezone.utc)
sub_stmt = (
select(OrganizationSubscription)
.options(selectinload(OrganizationSubscription.tier))
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
OrganizationSubscription.valid_until >= now,
or_(
OrganizationSubscription.valid_until >= now,
OrganizationSubscription.valid_until.is_(None),
),
)
.order_by(OrganizationSubscription.id.desc())
)
@@ -514,10 +682,14 @@ async def get_organization_details(
active_subs = sub_result.scalars().all()
# 2b. P0 ADD-ON: Valós járműszám lekérése a fleet táblából
from app.models.vehicle.vehicle import Vehicle # noqa: E402
vehicle_count_stmt = select(func.count(Vehicle.id)).where(
Vehicle.organization_id == org_id,
Vehicle.is_deleted == False,
# P0 CRITICAL FIX: Exclude archived/draft vehicles from quota count.
# Only count vehicles with status='active' to prevent quota abuse.
# DB schema (vehicle.assets): status values are 'active', 'archived', 'draft'.
# 'archived' = soft-deleted, 'draft' = incomplete registration.
from app.models.vehicle import Asset # noqa: E402
vehicle_count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == org_id,
Asset.status == 'active',
)
vehicle_count_result = await db.execute(vehicle_count_stmt)
asset_count = vehicle_count_result.scalar() or 0
@@ -532,10 +704,10 @@ async def get_organization_details(
branch_rows = branches_result.scalars().all()
branches_list = [
BranchBrief(
id=b.id,
id=str(b.id),
name=b.name,
city=b.city,
is_active=b.is_active,
is_active=(b.status == "active") if b.status else True,
)
for b in branch_rows
]
@@ -589,7 +761,9 @@ async def get_organization_details(
key=lambda s: s.tier.tier_level if s.tier else 0,
reverse=True,
)
primary_sub = base_subs_sorted[0] if base_subs_sorted else (active_subs[0] if active_subs else None)
# P0 CRITICAL FIX: primary_sub MUST be a base tier, NEVER an add-on.
# The fallback active_subs[0] could pick an add-on row if no base subs exist.
primary_sub = base_subs_sorted[0] if base_subs_sorted else None
if primary_sub and primary_sub.tier:
# ── Base limits from primary subscription (using _extract_limit) ──
@@ -610,7 +784,7 @@ async def get_organization_details(
base_users = _extract_limit(
primary_rules,
["max_users", "max_members"],
_extract_limit(primary_fc, ["max_users", "max_members"], 0),
_extract_limit(primary_fc, ["max_users", "max_members"], 1),
)
# ── Aggregate extra_allowances from ALL active subscriptions ──
@@ -652,9 +826,49 @@ async def get_organization_details(
# P0 HOTFIX: Clamping — ensure asset_limit >= 1 even if all sums are 0
asset_limit = max(asset_limit, 1)
# ── P0 ITEMIZED ADD-ON: Build add-on list ──
addon_list = []
# 1. Add-ons from dedicated addon-type subscriptions (P0 itemized path)
for addon_sub in addon_subs:
extra = addon_sub.extra_allowances or {}
for addon_type, qty in extra.items():
if qty:
valid_until_str = addon_sub.valid_until.isoformat() if addon_sub.valid_until else None
addon_list.append(AddonInfo(
type=addon_type,
quantity=qty,
subscription_id=addon_sub.id,
valid_until=valid_until_str,
expires_at=valid_until_str,
tier_name=addon_sub.tier.name if addon_sub.tier else None,
))
# 2. Legacy add-ons from base subscription JSONB extra_allowances
# (migration path: old data stored directly on base sub)
existing_addon_types = {a.type for a in addon_list}
for base_sub in base_subs:
legacy_extra = base_sub.extra_allowances or {}
for addon_type, qty in legacy_extra.items():
if qty and addon_type not in existing_addon_types:
valid_until_str = base_sub.valid_until.isoformat() if base_sub.valid_until else None
addon_list.append(AddonInfo(
type=addon_type,
quantity=qty,
subscription_id=base_sub.id,
# Legacy add-ons have no explicit valid_until;
# fall back to base subscription's expires_at
valid_until=valid_until_str,
expires_at=valid_until_str,
tier_name=None,
))
existing_addon_types.add(addon_type)
subscription_summary = SubscriptionSummary(
tier_name=primary_sub.tier.name,
tier_level=primary_sub.tier.tier_level,
tier_id=primary_sub.tier.id,
valid_from=primary_sub.valid_from.isoformat() if primary_sub.valid_from else None,
expires_at=primary_sub.valid_until.isoformat() if primary_sub.valid_until else None,
is_active=primary_sub.is_active,
@@ -663,6 +877,7 @@ async def get_organization_details(
branch_limit=branch_limit,
user_limit=user_limit,
extra_allowances=aggregated_extra,
addons=addon_list,
)
elif org.subscription_tier:
# Fallback: Organization.subscription_tier_id kapcsolat (nincs OrganizationSubscription)
@@ -679,7 +894,7 @@ async def get_organization_details(
)
base_users = _extract_limit(
rules, ["max_users", "max_members"],
_extract_limit(fc, ["max_users", "max_members"], 0),
_extract_limit(fc, ["max_users", "max_members"], 1),
)
# P0 HOTFIX: Clamping — ensure asset_limit >= 1 even for fallback path
base_vehicles = max(base_vehicles, 1)
@@ -694,6 +909,52 @@ async def get_organization_details(
user_limit=base_users,
)
# ── P0 AUDIT TRAIL: Fetch subscription history from audit.audit_logs ──
subscription_history: List[SubscriptionHistoryItem] = []
try:
audit_stmt = (
select(AuditLog)
.where(
AuditLog.target_type == "organization",
AuditLog.target_id == str(org_id),
AuditLog.action.in_(["SUBSCRIPTION_TIER_CHANGE", "SUBSCRIPTION_ADDON_OVERRIDE"]),
)
.order_by(AuditLog.timestamp.desc())
.limit(50)
)
audit_result = await db.execute(audit_stmt)
audit_rows = audit_result.scalars().all()
for log_entry in audit_rows:
# Build a human-readable changes_summary
changes_summary = None
tier_name = None
if log_entry.action == "SUBSCRIPTION_TIER_CHANGE":
old_tier = (log_entry.old_data or {}).get("tier_id")
new_tier_data = (log_entry.new_data or {})
new_tier = new_tier_data.get("tier_id")
new_tier_name = new_tier_data.get("tier_name", f"Tier #{new_tier}")
tier_name = new_tier_name
changes_summary = f"Csomag váltás: Tier #{old_tier}{new_tier_name}"
elif log_entry.action == "SUBSCRIPTION_ADDON_OVERRIDE":
old_addons = (log_entry.old_data or {}).get("addons", {})
new_addons = (log_entry.new_data or {}).get("addons", {})
changes_summary = f"Kiegészítők módosítása"
tier_name = "Kiegészítők"
subscription_history.append(SubscriptionHistoryItem(
id=log_entry.id,
action=log_entry.action,
admin_user_id=log_entry.user_id,
old_data=log_entry.old_data,
new_data=log_entry.new_data,
timestamp=log_entry.timestamp.isoformat() if log_entry.timestamp else None,
tier_name=tier_name,
changes_summary=changes_summary,
))
except Exception as e:
logger.warning(f"P0 AUDIT TRAIL: Failed to fetch subscription history for org {org_id}: {e}")
# 6. Kapcsolattartó adatok
primary_contact = None
if contact and contact.person:
@@ -859,6 +1120,8 @@ async def get_organization_details(
members=members_list,
# ── P0 ADD-ON UPGRADE: Branches list ──
branches=branches_list,
# ── P0 AUDIT TRAIL: Subscription history ──
subscription_history=subscription_history,
)
@@ -1105,7 +1368,8 @@ async def update_organization(
"/{org_id}/subscription",
summary="Garázs előfizetésének módosítása",
description="Frissíti vagy létrehozza egy garázs OrganizationSubscription rekordját. "
"Lehetőség van egyedi lejárati dátum megadására (pl. 2-5 éves B2B deal).",
"Lehetőség van egyedi lejárati dátum megadására (pl. 2-5 éves B2B deal). "
"P0 ITEMIZED ADD-ON: Minden add-on típus külön sorban tárolva.",
)
async def update_org_subscription(
org_id: int,
@@ -1117,13 +1381,39 @@ async def update_org_subscription(
"""
Garázs előfizetésének módosítása.
- Ellenőrzi, hogy a szervezet létezik-e.
- Ellenőrzi, hogy a SubscriptionTier létezik-e.
- Ha van aktív OrganizationSubscription, inaktiválja (is_active=False).
- Létrehoz egy új OrganizationSubscription rekordot a megadott tier_id-val
és opcionális expires_at dátummal.
- Ha expires_at nincs megadva, a csomag rules.duration.days alapján számol.
Két üzemmód:
Mode A (tier_id megadva — Teljes előfizetés váltás):
- Ellenőrzi, hogy a SubscriptionTier létezik-e.
- Inaktiválja a meglévő aktív OrganizationSubscription sorokat.
- Létrehoz egy új Base OrganizationSubscription rekordot a megadott tier_id-val.
- Létrehoz külön add-on sorokat minden pozitív extra_* értékhez.
Mode B (tier_id = None — Csak add-on frissítés):
- P0 ITEMIZED ADD-ON: Minden add-on típushoz külön OrganizationSubscription sort
hoz létre vagy frissít, ahelyett hogy egyetlen JSONB-t írna felül.
- Ha egy add-on értéke 0, a meglévő sor inaktiválódik.
- Ha egy add-on értéke > 0, létrejön/aktiválódik egy dedikált sor.
"""
# ── P0 AUDIT TRAIL: Capture previous subscription state before modification ──
previous_tier_id = None
previous_addons = {}
prev_sub_stmt = (
select(OrganizationSubscription)
.options(selectinload(OrganizationSubscription.tier))
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
)
.order_by(OrganizationSubscription.id.desc())
.limit(1)
)
prev_sub_result = await db.execute(prev_sub_stmt)
prev_sub = prev_sub_result.scalar_one_or_none()
if prev_sub:
previous_tier_id = prev_sub.tier_id
previous_addons = prev_sub.extra_allowances or {}
# 1. Ellenőrizzük a szervezetet
org_stmt = select(Organization).where(Organization.id == org_id)
org_result = await db.execute(org_stmt)
@@ -1135,6 +1425,198 @@ async def update_org_subscription(
detail=f"Szervezet nem található ID-vel: {org_id}",
)
now = datetime.utcnow()
# ── P0 ITEMIZED ADD-ON: Inner helper to upsert a single add-on row ──
async def _upsert_addon_sub(addon_type: str, quantity: int, expires: Optional[datetime]) -> Optional[int]:
"""Create or deactivate a single add-on subscription row.
Returns the ID of the created/updated row, or None if deactivated/skipped.
P0 CRITICAL SAFETY: When quantity <= 0, we ONLY deactivate existing rows.
We NEVER create new rows with zero quantity. This prevents the frontend
from accidentally creating zombie add-on rows when the form initializes
add-on values to 0 instead of reading from existing subscription data.
"""
if quantity <= 0:
# Deactivate any existing active add-on row for this type
# P0 CRITICAL FIX: Use JSONB .astext.is_not(None) to reliably find rows
# where this addon_type key exists in extra_allowances.
# The previous approach using [addon_type].as_integer().is_not(None) could
# fail if the JSONB structure doesn't match the expected path.
existing_stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
# Use JSONB containment: check if extra_allowances contains the key
OrganizationSubscription.extra_allowances[addon_type].astext.is_not(None),
)
.order_by(OrganizationSubscription.id.desc())
.limit(1)
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalar_one_or_none()
if existing:
existing.is_active = False
logger.info(
f"P0 ITEMIZED: Deactivated add-on row {existing.id} "
f"for {addon_type} (org {org_id})"
)
else:
logger.info(
f"P0 ITEMIZED: No active add-on row found for {addon_type} "
f"(org {org_id}) — skipping deactivation"
)
return None
# quantity > 0: find or create the add-on tier
addon_tier_id = await _find_or_create_addon_tier(db, addon_type)
if addon_tier_id is None:
logger.warning(f"P0 ITEMIZED: Could not find/create add-on tier for {addon_type}")
return None
# Normalize expiration
addon_valid_until = normalize_to_end_of_day(expires) if expires else None
# Check if there's already an active add-on row for this type
existing_stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
OrganizationSubscription.tier_id == addon_tier_id,
)
.order_by(OrganizationSubscription.id.desc())
.limit(1)
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalar_one_or_none()
if existing:
# Update existing row
existing.extra_allowances = {addon_type: quantity}
if addon_valid_until:
existing.valid_until = addon_valid_until
logger.info(
f"P0 ITEMIZED: Updated add-on row {existing.id} "
f"for {addon_type}={quantity} (org {org_id})"
)
return existing.id
else:
# Create new add-on row
new_addon = OrganizationSubscription(
org_id=org_id,
tier_id=addon_tier_id,
valid_from=now,
valid_until=addon_valid_until,
is_active=True,
extra_allowances={addon_type: quantity},
)
db.add(new_addon)
await db.flush()
logger.info(
f"P0 ITEMIZED: Created add-on row for {addon_type}={quantity} (org {org_id})"
)
return new_addon.id
# ── Mode B: tier_id is None — Only add-on update ──
if payload.tier_id is None:
# P0 ITEMIZED ADD-ON: Individual expiration dates per add-on type
addon_expires_map = {
"extra_vehicles": normalize_to_end_of_day(payload.extra_vehicles_expires_at) if payload.extra_vehicles_expires_at else None,
"extra_branches": normalize_to_end_of_day(payload.extra_branches_expires_at) if payload.extra_branches_expires_at else None,
"extra_users": normalize_to_end_of_day(payload.extra_users_expires_at) if payload.extra_users_expires_at else None,
}
# Upsert each add-on type
addon_results = {}
addon_ids = []
for addon_type, quantity in [
("extra_vehicles", payload.extra_vehicles),
("extra_branches", payload.extra_branches),
("extra_users", payload.extra_users),
]:
row_id = await _upsert_addon_sub(addon_type, quantity, addon_expires_map[addon_type])
addon_results[addon_type] = row_id
if row_id:
addon_ids.append(row_id)
# ── P0 MIGRATION: Clear legacy extra_allowances on the base subscription ──
# When Mode B creates itemized add-on rows, we MUST clear the legacy
# extra_allowances on the base subscription to prevent double-counting.
# Set to 0 for the add-on types that were explicitly provided in the payload.
base_sub_stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
OrganizationSubscription.tier_id.isnot(None), # Base subscription has a real tier_id
)
.order_by(OrganizationSubscription.id.desc())
.limit(1)
)
base_sub_result = await db.execute(base_sub_stmt)
base_sub = base_sub_result.scalar_one_or_none()
if base_sub and base_sub.extra_allowances:
legacy_cleanup = {}
for addon_key in ["extra_vehicles", "extra_branches", "extra_users"]:
if getattr(payload, addon_key, 0) > 0:
legacy_cleanup[addon_key] = 0
if legacy_cleanup:
merged = dict(base_sub.extra_allowances)
merged.update(legacy_cleanup)
base_sub.extra_allowances = merged
logger.info(
f"P0 MIGRATION: Cleared legacy extra_allowances on base sub {base_sub.id} "
f"for org {org_id}: {legacy_cleanup}"
)
# ── P0 AUDIT TRAIL: Log add-on override to audit.audit_logs ──
new_addons_data = {
"extra_vehicles": payload.extra_vehicles,
"extra_branches": payload.extra_branches,
"extra_users": payload.extra_users,
}
audit_entry = AuditLog(
user_id=current_user.id,
severity=LogSeverity.info,
action="SUBSCRIPTION_ADDON_OVERRIDE",
target_type="organization",
target_id=str(org_id),
old_data={"addons": previous_addons},
new_data={"addons": new_addons_data},
)
db.add(audit_entry)
await db.commit()
# Build response with itemized add-on list
addon_list = []
for addon_type, row_id in addon_results.items():
addon_list.append({
"type": addon_type,
"quantity": getattr(payload, addon_type),
"subscription_id": row_id,
})
logger.info(
f"Admin {current_user.id} ({current_user.email}) updated add-ons for org {org_id}: "
f"extra_vehicles={payload.extra_vehicles}, "
f"extra_branches={payload.extra_branches}, "
f"extra_users={payload.extra_users} "
f"(Mode B, ITEMIZED)"
)
return {
"status": "success",
"message": f"Add-on-ok frissítve a '{org.full_name}' garázs számára.",
"addons": addon_list,
}
# ── Mode A: tier_id is provided — Full subscription change ──
# 2. Ellenőrizzük a SubscriptionTier-t
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == payload.tier_id)
tier_result = await db.execute(tier_stmt)
@@ -1157,7 +1639,6 @@ async def update_org_subscription(
)
# 4. Számoljuk a lejárati dátumot
now = datetime.utcnow()
if payload.expires_at:
valid_until = payload.expires_at
else:
@@ -1168,31 +1649,73 @@ async def update_org_subscription(
from datetime import timedelta
valid_until = now + timedelta(days=days)
# 5. P0 ADD-ON: Összeállítjuk az extra_allowances JSONB-t
extra_allowances = {}
if payload.extra_vehicles > 0:
extra_allowances["extra_vehicles"] = payload.extra_vehicles
if payload.extra_branches > 0:
extra_allowances["extra_branches"] = payload.extra_branches
if payload.extra_users > 0:
extra_allowances["extra_users"] = payload.extra_users
# Normalize base subscription expiration
valid_until = normalize_to_end_of_day(valid_until)
# 6. Létrehozzuk az új subscription rekordot
# 5. Létrehozzuk az új Base subscription rekordot (extra_allowances üresen)
new_sub = OrganizationSubscription(
org_id=org_id,
tier_id=payload.tier_id,
valid_from=now,
valid_until=valid_until,
is_active=True,
extra_allowances=extra_allowances,
extra_allowances={}, # Base tier has no extra allowances
)
db.add(new_sub)
await db.flush()
# 6. P0 ITEMIZED ADD-ON: Create separate add-on rows for each add-on type
# Each add-on type gets its OWN expiration date from the payload.
# Falls back to the base subscription expires_at if individual date is not set.
addon_list = []
addon_type_config = [
("extra_vehicles", payload.extra_vehicles, payload.extra_vehicles_expires_at),
("extra_branches", payload.extra_branches, payload.extra_branches_expires_at),
("extra_users", payload.extra_users, payload.extra_users_expires_at),
]
for addon_type, quantity, individual_expires in addon_type_config:
# Use individual date if provided, otherwise fall back to base subscription expires_at
addon_expires = normalize_to_end_of_day(individual_expires or payload.expires_at)
row_id = await _upsert_addon_sub(addon_type, quantity, addon_expires)
if row_id:
addon_list.append({
"type": addon_type,
"quantity": quantity,
"subscription_id": row_id,
})
# 7. Frissítjük a Organization denormalizált mezőit is
org.subscription_tier_id = payload.tier_id
org.subscription_expires_at = valid_until
org.subscription_plan = tier.name
# ── P0 AUDIT TRAIL: Log full subscription tier change to audit.audit_logs ──
new_tier_data = {
"tier_id": payload.tier_id,
"tier_name": tier.name,
"valid_until": valid_until.isoformat() if valid_until else None,
"addons": {
"extra_vehicles": payload.extra_vehicles,
"extra_branches": payload.extra_branches,
"extra_users": payload.extra_users,
},
}
audit_entry = AuditLog(
user_id=current_user.id,
severity=LogSeverity.info,
action="SUBSCRIPTION_TIER_CHANGE",
target_type="organization",
target_id=str(org_id),
old_data={
"tier_id": previous_tier_id,
"addons": previous_addons,
},
new_data=new_tier_data,
)
db.add(audit_entry)
await db.commit()
await db.refresh(new_sub)
@@ -1200,7 +1723,7 @@ async def update_org_subscription(
f"Admin {current_user.id} ({current_user.email}) updated subscription for org {org_id}: "
f"tier_id={payload.tier_id}, tier_name={tier.name}, "
f"valid_until={valid_until.isoformat()}, "
f"extra_allowances={extra_allowances}"
f"addons={addon_list}"
)
return {
@@ -1214,8 +1737,9 @@ async def update_org_subscription(
"valid_from": new_sub.valid_from.isoformat() if new_sub.valid_from else None,
"valid_until": new_sub.valid_until.isoformat() if new_sub.valid_until else None,
"is_active": new_sub.is_active,
"extra_allowances": extra_allowances,
"extra_allowances": {},
},
"addons": addon_list,
}
@@ -1562,17 +2086,22 @@ async def get_organization_vehicles(
# - közvetlenül a tulajdonában van (owner_org_id), VAGY
# - jelenleg oda van rendelve (current_organization_id)
# NEM kell AssetAssignment JOIN — a direkt ownership mezők az Asset táblában vannak.
#
# P0 CRITICAL FIX: Filter by status == 'active' to exclude archived/draft vehicles
# from the fleet list AND from the quota count. Without this filter, archived
# vehicles still appear in the fleet tab and consume quota slots.
ownership_filter = or_(
Asset.owner_org_id == org_id,
Asset.current_organization_id == org_id,
)
active_filter = Asset.status == 'active'
# P0 HOTFIX: Asset.branch relationship nem létezik a modellben.
# Explicit LEFT JOIN a fleet.branches táblához a branch_name lekéréséhez.
vehicle_stmt = (
select(Asset, Branch.name.label("branch_name"))
.outerjoin(Branch, Asset.branch_id == Branch.id)
.where(ownership_filter)
.where(ownership_filter, active_filter)
.order_by(Asset.created_at.desc())
.offset(skip)
.limit(limit)
@@ -1580,9 +2109,9 @@ async def get_organization_vehicles(
vehicle_result = await db.execute(vehicle_stmt)
rows = vehicle_result.all()
# Teljes számolás
# Teljes számolás (also filtered by active status)
count_stmt = select(func.count()).select_from(
select(Asset).where(ownership_filter).subquery()
select(Asset).where(ownership_filter, active_filter).subquery()
)
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View 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