771 lines
31 KiB
Python
771 lines
31 KiB
Python
"""
|
|
👤 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
|
|
DELETE /admin/users/{user_id}/hard — [SUPERADMIN] Végleges felhasználó törlés
|
|
|
|
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.identity.identity import Wallet
|
|
from app.models.marketplace.organization import Organization, OrganizationMember
|
|
from app.models.system.audit import SecurityAuditLog, FinancialLedger
|
|
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
|
|
|
|
|
|
# =============================================================================
|
|
# Hard Delete Endpoint (Danger Zone)
|
|
# =============================================================================
|
|
|
|
|
|
@router.delete(
|
|
"/{user_id}/hard",
|
|
summary="[SUPERADMIN] Felhasználó végleges törlése",
|
|
description=(
|
|
"Véglegesen eltávolít egy felhasználót az adatbázisból. "
|
|
"KIZÁRÓLAG Superadmin számára elérhető. "
|
|
"Gatekeeper: Ellenőrzi, hogy a felhasználónak nincs-e pénzügyi rekordja "
|
|
"(FinancialLedger, Wallet) a törlés előtt. "
|
|
"Audit trail: SecurityAuditLog-ba rögzíti a műveletet."
|
|
),
|
|
)
|
|
async def hard_delete_user(
|
|
user_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(deps.get_current_active_user),
|
|
_ = Depends(deps.RequirePermission("user:manage")),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
DELETE /admin/users/{user_id}/hard
|
|
|
|
Véglegesen töröl egy felhasználót az adatbázisból.
|
|
Csak Superadmin jogosultsággal érhető el.
|
|
"""
|
|
# ── 1. Superadmin ellenőrzés ──────────────────────────────────────────
|
|
if current_user.role != UserRole.SUPERADMIN:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only Superadmins can perform hard delete operations."
|
|
)
|
|
|
|
# ── 2. Felhasználó lekérése ───────────────────────────────────────────
|
|
stmt = select(User).where(User.id == user_id)
|
|
result = await db.execute(stmt)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"User with ID {user_id} not found."
|
|
)
|
|
|
|
# ── 3. Gatekeeper: Pénzügyi rekordok ellenőrzése ──────────────────────
|
|
# FinancialLedger ellenőrzés
|
|
ledger_stmt = select(FinancialLedger).where(FinancialLedger.user_id == user_id).limit(1)
|
|
ledger_result = await db.execute(ledger_stmt)
|
|
has_ledger = ledger_result.scalar_one_or_none() is not None
|
|
|
|
# Wallet ellenőrzés
|
|
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id).limit(1)
|
|
wallet_result = await db.execute(wallet_stmt)
|
|
has_wallet = wallet_result.scalar_one_or_none() is not None
|
|
|
|
if has_ledger or has_wallet:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"Cannot hard delete: User has financial or production records. "
|
|
"Please ensure all financial data is cleared before deletion."
|
|
)
|
|
)
|
|
|
|
# ── 4. FK referenciák nullázása a törlés előtt ─────────────────────────
|
|
# Az AuditLog (audit.audit_logs) és SecurityAuditLog (audit.security_audit_logs)
|
|
# táblák FK constrainttel hivatkoznak identity.users.id-ra, de NINCS
|
|
# ondelete="SET NULL" beállítva. Ezért manuálisan nullázzuk a referenciákat.
|
|
user_data = {
|
|
"user_id": user.id,
|
|
"email": user.email,
|
|
"role": str(user.role) if user.role else None,
|
|
"is_active": user.is_active,
|
|
"is_deleted": user.is_deleted,
|
|
}
|
|
|
|
# AuditLog.user_id nullázása
|
|
await db.execute(
|
|
sa_update(AuditLog)
|
|
.where(AuditLog.user_id == user_id)
|
|
.values(user_id=None)
|
|
)
|
|
|
|
# ── 5. Végleges törlés ─────────────────────────────────────────────────
|
|
await db.delete(user)
|
|
await db.flush()
|
|
|
|
# ── 6. Audit Trail ─────────────────────────────────────────────────────
|
|
# SecurityAuditLog.target_id FK-val hivatkozik identity.users.id-ra,
|
|
# de nincs ondelete="SET NULL". Mivel a user már törölve van,
|
|
# a target_id=None kell legyen, különben ForeignKeyViolationError.
|
|
# A tényleges user_id a payload_before mezőben kerül tárolásra.
|
|
audit_log = SecurityAuditLog(
|
|
actor_id=current_user.id,
|
|
target_id=None,
|
|
action="hard_delete_user",
|
|
is_critical=True,
|
|
payload_before=user_data,
|
|
payload_after={
|
|
"details": f"User #{user_id} ({user.email}) permanently deleted by admin #{current_user.id}."
|
|
},
|
|
)
|
|
db.add(audit_log)
|
|
|
|
await db.commit()
|
|
|
|
logger.warning(
|
|
"HARD DELETE: User #%s (%s) permanently deleted by admin #%s",
|
|
user_id, user.email, current_user.id
|
|
)
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": f"User #{user_id} has been permanently deleted.",
|
|
"deleted_user_id": user_id,
|
|
"deleted_email": user.email,
|
|
}
|