""" 👤 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