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

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