1085 lines
42 KiB
Python
1085 lines
42 KiB
Python
"""
|
|
👤 Admin Persons API
|
|
|
|
Végpontok:
|
|
GET /admin/persons — Person lista (keresés, szűrés, lapozás)
|
|
GET /admin/persons/{person_id} — Person részletes adatai
|
|
PATCH /admin/persons/{person_id} — Person szerkesztése
|
|
POST /admin/persons/{person_id}/merge — Person merge (duplum kezelés)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, date
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from sqlalchemy import select, func, or_, and_, update as sa_update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload, 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 AddressResponse
|
|
|
|
logger = logging.getLogger("admin-persons")
|
|
router = APIRouter()
|
|
|
|
|
|
# =============================================================================
|
|
# Pydantic Schemas
|
|
# =============================================================================
|
|
|
|
|
|
class PersonListItemActiveUser(BaseModel):
|
|
"""Aktív felhasználó adatai a Person lista elemben."""
|
|
id: int
|
|
email: Optional[str] = None
|
|
role: Optional[str] = None
|
|
|
|
|
|
class PersonListItem(BaseModel):
|
|
"""Egy Person rekord adatai a listában.
|
|
|
|
Megfelel a logic_spec_*.md 4.4-es szekció PersonListResponse struktúrájának.
|
|
"""
|
|
id: int
|
|
last_name: str
|
|
first_name: str
|
|
phone: Optional[str] = None
|
|
birth_date: Optional[str] = None
|
|
is_ghost: bool = False
|
|
is_active: bool = True
|
|
merged_into_id: Optional[int] = None
|
|
users_count: int = 0
|
|
active_user: Optional[PersonListItemActiveUser] = None
|
|
address: Optional[AddressResponse] = None
|
|
|
|
|
|
class PersonListResponse(BaseModel):
|
|
"""Person lista válasz."""
|
|
total: int
|
|
skip: int
|
|
limit: int
|
|
items: List[PersonListItem]
|
|
|
|
|
|
# =============================================================================
|
|
# GET / — Person lista
|
|
# =============================================================================
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=PersonListResponse,
|
|
summary="Person lista lekérése",
|
|
description=(
|
|
"Visszaadja az összes Person rekordot lapozással, kereséssel és szűréssel. "
|
|
"Támogatott szűrők: is_ghost, is_active, has_user (van-e kapcsolódó User), "
|
|
"is_merged (már merge-elve van-e). Keresés név, telefonszám alapján."
|
|
),
|
|
)
|
|
async def list_persons(
|
|
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
|
|
limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
|
|
search: Optional[str] = Query(None, description="Keresés név vagy telefonszám alapján"),
|
|
is_ghost: Optional[bool] = Query(None, description="Szűrés ghost státusz szerint"),
|
|
is_active: Optional[bool] = Query(None, description="Szűrés aktív/inaktív szerint"),
|
|
has_user: Optional[bool] = Query(None, description="Szűrés aszerint, hogy van-e kapcsolódó User"),
|
|
is_merged: Optional[bool] = Query(None, description="Szűrés merge státusz szerint (van-e merged_into_id)"),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(deps.get_current_active_user),
|
|
_ = Depends(deps.RequirePermission("persons:view")),
|
|
) -> PersonListResponse:
|
|
"""
|
|
GET /admin/persons
|
|
|
|
Visszaadja az összes Person rekordot a megadott szűrők és lapozás alapján.
|
|
Minden Person-hoz visszaadja:
|
|
- Alapadatokat (név, telefonszám, születési dátum)
|
|
- Kapcsolódó userek számát
|
|
- Az aktív user adatait (email, role)
|
|
- Cím adatokat (ha van)
|
|
"""
|
|
# Alap lekérdezés — Person + User kapcsolat
|
|
base_query = (
|
|
select(Person)
|
|
.options(
|
|
joinedload(Person.address),
|
|
joinedload(Person.users),
|
|
)
|
|
.execution_options(innerjoin=False)
|
|
)
|
|
|
|
# ── Szűrés ──
|
|
conditions = []
|
|
|
|
# Keresés név vagy telefonszám alapján
|
|
if search:
|
|
like_pattern = f"%{search}%"
|
|
conditions.append(
|
|
or_(
|
|
Person.last_name.ilike(like_pattern),
|
|
Person.first_name.ilike(like_pattern),
|
|
Person.phone.ilike(like_pattern),
|
|
)
|
|
)
|
|
|
|
# Ghost státusz szűrés
|
|
if is_ghost is not None:
|
|
conditions.append(Person.is_ghost == is_ghost)
|
|
|
|
# Aktív/Inaktív szűrés
|
|
if is_active is not None:
|
|
conditions.append(Person.is_active == is_active)
|
|
|
|
# Merge státusz szűrés
|
|
if is_merged is not None:
|
|
if is_merged:
|
|
conditions.append(Person.merged_into_id.isnot(None))
|
|
else:
|
|
conditions.append(Person.merged_into_id.is_(None))
|
|
|
|
# Van-e User kapcsolat
|
|
if has_user is not None:
|
|
# Subquery: Person-ok akiknek van legalább egy User rekordjuk
|
|
user_subq = (
|
|
select(User.person_id)
|
|
.where(User.person_id.isnot(None))
|
|
.distinct()
|
|
.subquery()
|
|
)
|
|
if has_user:
|
|
conditions.append(Person.id.in_(select(user_subq.c.person_id)))
|
|
else:
|
|
conditions.append(Person.id.notin_(select(user_subq.c.person_id)))
|
|
|
|
# Alkalmazzuk a feltételeket
|
|
if conditions:
|
|
base_query = base_query.where(and_(*conditions))
|
|
|
|
# Teljes számolás
|
|
count_query = select(func.count()).select_from(base_query.subquery())
|
|
total_result = await db.execute(count_query)
|
|
total = total_result.scalar() or 0
|
|
|
|
# Lapozás
|
|
persons_query = base_query.order_by(Person.id.desc()).offset(skip).limit(limit)
|
|
persons_result = await db.execute(persons_query)
|
|
persons = persons_result.unique().scalars().all()
|
|
|
|
# Person ID-k begyűjtése a tömeges user lekérdezéshez
|
|
person_ids = [p.id for p in persons]
|
|
|
|
# Tömeges user lekérdezés (hogy ne N+1 query legyen)
|
|
users_map: Dict[int, List[User]] = {}
|
|
if person_ids:
|
|
user_stmt = (
|
|
select(User)
|
|
.where(
|
|
User.person_id.in_(person_ids),
|
|
User.is_deleted == False,
|
|
)
|
|
.order_by(User.id)
|
|
)
|
|
user_result = await db.execute(user_stmt)
|
|
for user in user_result.scalars().all():
|
|
if user.person_id not in users_map:
|
|
users_map[user.person_id] = []
|
|
users_map[user.person_id].append(user)
|
|
|
|
# Válasz összeállítása
|
|
items: List[PersonListItem] = []
|
|
for person in persons:
|
|
# Kapcsolódó userek
|
|
related_users = users_map.get(person.id, [])
|
|
users_count = len(related_users)
|
|
|
|
# Aktív user (első nem törölt user)
|
|
active_user_data = None
|
|
if related_users:
|
|
first_user = related_users[0]
|
|
active_user_data = PersonListItemActiveUser(
|
|
id=first_user.id,
|
|
email=first_user.email,
|
|
role=first_user.role.value if hasattr(first_user.role, "value") else str(first_user.role),
|
|
)
|
|
|
|
# Cím adatok
|
|
address_data = None
|
|
if person.address:
|
|
address_data = AddressResponse(
|
|
address_zip=person.address.zip,
|
|
address_city=person.address.city,
|
|
address_street_name=person.address.street_name,
|
|
address_street_type=person.address.street_type,
|
|
address_house_number=person.address.house_number,
|
|
address_stairwell=person.address.stairwell,
|
|
address_floor=person.address.floor,
|
|
address_door=person.address.door,
|
|
address_hrsz=person.address.parcel_id,
|
|
full_address_text=person.address.full_address_text,
|
|
latitude=person.address.latitude,
|
|
longitude=person.address.longitude,
|
|
)
|
|
|
|
items.append(PersonListItem(
|
|
id=person.id,
|
|
last_name=person.last_name or "",
|
|
first_name=person.first_name or "",
|
|
phone=person.phone,
|
|
birth_date=person.birth_date.isoformat() if person.birth_date else None,
|
|
is_ghost=person.is_ghost,
|
|
is_active=person.is_active,
|
|
merged_into_id=person.merged_into_id,
|
|
users_count=users_count,
|
|
active_user=active_user_data,
|
|
address=address_data,
|
|
))
|
|
|
|
logger.info(
|
|
f"Admin {current_user.id} ({current_user.email}) fetched persons list: "
|
|
f"total={total}, returned={len(items)}, "
|
|
f"search={search}, is_ghost={is_ghost}, is_active={is_active}, "
|
|
f"has_user={has_user}, is_merged={is_merged}"
|
|
)
|
|
|
|
return PersonListResponse(
|
|
total=total,
|
|
skip=skip,
|
|
limit=limit,
|
|
items=items,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# GET /{person_id} — Person részletek
|
|
# =============================================================================
|
|
|
|
|
|
class PersonDetailUserInfo(BaseModel):
|
|
"""User adatok a Person részletek nézetben."""
|
|
id: int
|
|
email: str
|
|
role: Optional[str] = None
|
|
role_id: Optional[int] = None
|
|
is_active: bool = True
|
|
is_deleted: bool = False
|
|
subscription_plan: str = "FREE"
|
|
is_vip: bool = False
|
|
preferred_language: str = "hu"
|
|
region_code: str = "HU"
|
|
preferred_currency: str = "HUF"
|
|
created_at: Optional[str] = None
|
|
|
|
|
|
class PersonDetailMembership(BaseModel):
|
|
"""Szervezeti tagság adatai a Person részletek nézetben."""
|
|
id: int
|
|
organization_id: int
|
|
organization_name: Optional[str] = None
|
|
organization_type: Optional[str] = None
|
|
role: str = "MEMBER"
|
|
is_verified: bool = False
|
|
status: str = "active"
|
|
created_at: Optional[str] = None
|
|
|
|
|
|
class PersonDetailOwnedOrganization(BaseModel):
|
|
"""Tulajdonolt szervezet adatai a Person részletek nézetben."""
|
|
id: int
|
|
name: str
|
|
org_type: Optional[str] = None
|
|
is_active: bool = True
|
|
created_at: Optional[str] = None
|
|
|
|
|
|
class PersonDetailMergeHistory(BaseModel):
|
|
"""Merge history adatok."""
|
|
id: int
|
|
merged_into_id: Optional[int] = None
|
|
merged_into_name: Optional[str] = None
|
|
merged_at: Optional[str] = None
|
|
|
|
|
|
class PersonDetailResponse(BaseModel):
|
|
"""Teljes Person adatlap válasz.
|
|
|
|
Megfelel a logic_spec_*.md 4.5-ös szekciójának:
|
|
Teljes Person adatlap kapcsolódó User-ekkel, szervezeti tagságokkal,
|
|
tulajdonolt cégekkel, merge history-val.
|
|
"""
|
|
id: int
|
|
id_uuid: str
|
|
last_name: str
|
|
first_name: str
|
|
phone: Optional[str] = None
|
|
mothers_last_name: Optional[str] = None
|
|
mothers_first_name: Optional[str] = None
|
|
birth_place: Optional[str] = None
|
|
birth_date: Optional[str] = None
|
|
identity_docs: Any = {}
|
|
ice_contact: Any = {}
|
|
is_ghost: bool = False
|
|
is_active: bool = True
|
|
is_sales_agent: bool = False
|
|
lifetime_xp: int = 0
|
|
penalty_points: int = 0
|
|
social_reputation: float = 0.0
|
|
merged_into_id: Optional[int] = None
|
|
deleted_at: Optional[str] = None
|
|
created_at: Optional[str] = None
|
|
updated_at: Optional[str] = None
|
|
|
|
# Kapcsolódó entitások
|
|
address: Optional[AddressResponse] = None
|
|
users: List[PersonDetailUserInfo] = []
|
|
memberships: List[PersonDetailMembership] = []
|
|
owned_organizations: List[PersonDetailOwnedOrganization] = []
|
|
merge_history: Optional[PersonDetailMergeHistory] = None
|
|
|
|
|
|
@router.get(
|
|
"/{person_id}",
|
|
response_model=PersonDetailResponse,
|
|
summary="Person részletes adatainak lekérése",
|
|
description=(
|
|
"Visszaadja egy Person rekord összes adatát, beleértve a kapcsolódó "
|
|
"User-eket, szervezeti tagságokat, tulajdonolt cégeket és merge history-t."
|
|
),
|
|
)
|
|
async def get_person_detail(
|
|
person_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(deps.get_current_active_user),
|
|
_ = Depends(deps.RequirePermission("persons:view")),
|
|
) -> PersonDetailResponse:
|
|
"""
|
|
GET /admin/persons/{person_id}
|
|
|
|
Teljes Person adatlap:
|
|
- Alapadatok (név, telefonszám, születési adatok, identity_docs, ice_contact)
|
|
- Cím adatok
|
|
- Kapcsolódó User-ek listája
|
|
- Szervezeti tagságok (OrganizationMember)
|
|
- Tulajdonolt szervezetek (ahol a Person a legal_owner)
|
|
- Merge history (ha merged_into_id van)
|
|
"""
|
|
# Person lekérdezése kapcsolatokkal
|
|
stmt = (
|
|
select(Person)
|
|
.options(
|
|
joinedload(Person.address),
|
|
joinedload(Person.users),
|
|
joinedload(Person.memberships).joinedload(OrganizationMember.organization),
|
|
joinedload(Person.owned_business_entities),
|
|
)
|
|
.where(Person.id == person_id)
|
|
)
|
|
result = await db.execute(stmt)
|
|
person = result.unique().scalar_one_or_none()
|
|
|
|
if not person:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Person with id {person_id} not found",
|
|
)
|
|
|
|
# ── Cím adatok ──
|
|
address_data = None
|
|
if person.address:
|
|
address_data = AddressResponse(
|
|
address_zip=person.address.zip,
|
|
address_city=person.address.city,
|
|
address_street_name=person.address.street_name,
|
|
address_street_type=person.address.street_type,
|
|
address_house_number=person.address.house_number,
|
|
address_stairwell=person.address.stairwell,
|
|
address_floor=person.address.floor,
|
|
address_door=person.address.door,
|
|
address_hrsz=person.address.parcel_id,
|
|
full_address_text=person.address.full_address_text,
|
|
latitude=person.address.latitude,
|
|
longitude=person.address.longitude,
|
|
)
|
|
|
|
# ── Kapcsolódó User-ek ──
|
|
users_data: List[PersonDetailUserInfo] = []
|
|
if person.users:
|
|
for user in person.users:
|
|
if user.is_deleted:
|
|
continue
|
|
users_data.append(PersonDetailUserInfo(
|
|
id=user.id,
|
|
email=user.email,
|
|
role=user.role.value if hasattr(user.role, "value") else str(user.role),
|
|
role_id=user.role_id,
|
|
is_active=user.is_active,
|
|
is_deleted=user.is_deleted,
|
|
subscription_plan=user.subscription_plan,
|
|
is_vip=user.is_vip,
|
|
preferred_language=user.preferred_language,
|
|
region_code=user.region_code,
|
|
preferred_currency=user.preferred_currency,
|
|
created_at=user.created_at.isoformat() if user.created_at else None,
|
|
))
|
|
|
|
# ── Szervezeti tagságok ──
|
|
memberships_data: List[PersonDetailMembership] = []
|
|
if person.memberships:
|
|
for m in person.memberships:
|
|
org_name = m.organization.name if m.organization else None
|
|
org_type = m.organization.org_type.value if m.organization and hasattr(m.organization.org_type, "value") else (
|
|
m.organization.org_type if m.organization else None
|
|
)
|
|
memberships_data.append(PersonDetailMembership(
|
|
id=m.id,
|
|
organization_id=m.organization_id,
|
|
organization_name=org_name,
|
|
organization_type=org_type,
|
|
role=m.role if not hasattr(m.role, "value") else m.role.value,
|
|
is_verified=m.is_verified,
|
|
status=m.status,
|
|
created_at=m.created_at.isoformat() if m.created_at else None,
|
|
))
|
|
|
|
# ── Tulajdonolt szervezetek ──
|
|
owned_orgs_data: List[PersonDetailOwnedOrganization] = []
|
|
if person.owned_business_entities:
|
|
for org in person.owned_business_entities:
|
|
owned_orgs_data.append(PersonDetailOwnedOrganization(
|
|
id=org.id,
|
|
name=org.name,
|
|
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
|
is_active=org.is_active,
|
|
created_at=org.created_at.isoformat() if org.created_at else None,
|
|
))
|
|
|
|
# ── Merge history ──
|
|
merge_history_data = None
|
|
if person.merged_into_id:
|
|
# Lekérdezzük a target Person nevét
|
|
target_person_stmt = select(Person).where(Person.id == person.merged_into_id)
|
|
target_result = await db.execute(target_person_stmt)
|
|
target_person = target_result.scalar_one_or_none()
|
|
target_name = None
|
|
if target_person:
|
|
target_name = f"{target_person.last_name} {target_person.first_name}".strip()
|
|
|
|
merge_history_data = PersonDetailMergeHistory(
|
|
id=person.id,
|
|
merged_into_id=person.merged_into_id,
|
|
merged_into_name=target_name,
|
|
merged_at=person.updated_at.isoformat() if person.updated_at else None,
|
|
)
|
|
|
|
logger.info(
|
|
f"Admin {current_user.id} ({current_user.email}) fetched person detail: "
|
|
f"person_id={person_id}, users={len(users_data)}, "
|
|
f"memberships={len(memberships_data)}, owned_orgs={len(owned_orgs_data)}"
|
|
)
|
|
|
|
return PersonDetailResponse(
|
|
id=person.id,
|
|
id_uuid=str(person.id_uuid),
|
|
last_name=person.last_name or "",
|
|
first_name=person.first_name or "",
|
|
phone=person.phone,
|
|
mothers_last_name=person.mothers_last_name,
|
|
mothers_first_name=person.mothers_first_name,
|
|
birth_place=person.birth_place,
|
|
birth_date=person.birth_date.isoformat() if person.birth_date else None,
|
|
identity_docs=person.identity_docs or {},
|
|
ice_contact=person.ice_contact or {},
|
|
is_ghost=person.is_ghost,
|
|
is_active=person.is_active,
|
|
is_sales_agent=person.is_sales_agent,
|
|
lifetime_xp=person.lifetime_xp,
|
|
penalty_points=person.penalty_points,
|
|
social_reputation=float(person.social_reputation) if person.social_reputation else 0.0,
|
|
merged_into_id=person.merged_into_id,
|
|
deleted_at=person.deleted_at.isoformat() if person.deleted_at else None,
|
|
created_at=person.created_at.isoformat() if person.created_at else None,
|
|
updated_at=person.updated_at.isoformat() if person.updated_at else None,
|
|
address=address_data,
|
|
users=users_data,
|
|
memberships=memberships_data,
|
|
owned_organizations=owned_orgs_data,
|
|
merge_history=merge_history_data,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# PATCH /{person_id} — Person szerkesztése
|
|
# =============================================================================
|
|
|
|
|
|
class PersonAddressUpdate(BaseModel):
|
|
"""Cím adatok a Person szerkesztéshez."""
|
|
address_zip: Optional[str] = Field(default=None, description="Irányítószám")
|
|
address_city: Optional[str] = Field(default=None, description="Város")
|
|
address_street_name: Optional[str] = Field(default=None, description="Utca név")
|
|
address_street_type: Optional[str] = Field(default=None, description="Közterület jellege")
|
|
address_house_number: Optional[str] = Field(default=None, description="Házszám")
|
|
address_stairwell: Optional[str] = Field(default=None, description="Lépcsőház")
|
|
address_floor: Optional[str] = Field(default=None, description="Emelet")
|
|
address_door: Optional[str] = Field(default=None, description="Ajtó")
|
|
address_hrsz: Optional[str] = Field(default=None, description="Helyrajzi szám")
|
|
full_address_text: Optional[str] = Field(default=None, description="Teljes cím szöveg")
|
|
latitude: Optional[float] = Field(default=None, description="GPS szélesség")
|
|
longitude: Optional[float] = Field(default=None, description="GPS hosszúság")
|
|
|
|
|
|
class PersonUpdateRequest(BaseModel):
|
|
"""Admin által szerkeszthető Person mezők.
|
|
|
|
Megfelel a logic_spec_*.md 4.6-os szekciójának.
|
|
Csak a megadott mezők módosíthatók admin felületről.
|
|
"""
|
|
last_name: Optional[str] = Field(default=None, min_length=1, max_length=100, description="Vezetéknév")
|
|
first_name: Optional[str] = Field(default=None, min_length=1, max_length=100, description="Keresztnév")
|
|
phone: Optional[str] = Field(default=None, max_length=30, description="Telefonszám")
|
|
mothers_last_name: Optional[str] = Field(default=None, max_length=100, description="Anyja születési vezetékneve")
|
|
mothers_first_name: Optional[str] = Field(default=None, max_length=100, description="Anyja születési keresztneve")
|
|
birth_place: Optional[str] = Field(default=None, max_length=100, description="Születési hely")
|
|
birth_date: Optional[date] = Field(default=None, description="Születési dátum (ISO: YYYY-MM-DD)")
|
|
identity_docs: Optional[Dict[str, Any]] = Field(default=None, description="Személyi okmány adatok")
|
|
ice_contact: Optional[Dict[str, Any]] = Field(default=None, description="Emergency contact adatok")
|
|
is_active: Optional[bool] = Field(default=None, description="Aktív státusz")
|
|
is_ghost: Optional[bool] = Field(default=None, description="Ghost státusz")
|
|
|
|
# Cím adatok (opcionális, nested object)
|
|
address: Optional[PersonAddressUpdate] = Field(default=None, description="Cím adatok")
|
|
|
|
@field_validator("phone")
|
|
@classmethod
|
|
def validate_phone(cls, v: Optional[str]) -> Optional[str]:
|
|
if v is not None and v.strip() == "":
|
|
return None
|
|
return v
|
|
|
|
@field_validator("last_name", "first_name")
|
|
@classmethod
|
|
def validate_name_not_empty(cls, v: Optional[str]) -> Optional[str]:
|
|
if v is not None and v.strip() == "":
|
|
raise ValueError("A név mező nem lehet üres.")
|
|
return v
|
|
|
|
|
|
@router.patch(
|
|
"/{person_id}",
|
|
response_model=PersonDetailResponse,
|
|
summary="Person adatainak szerkesztése",
|
|
description=(
|
|
"Admin általi Person szerkesztés. Csak a megadott mezők módosíthatók. "
|
|
"Támogatja a személyes adatok (név, telefonszám, születési adatok, "
|
|
"identity_docs, ice_contact) és a cím adatok módosítását."
|
|
),
|
|
)
|
|
async def update_person(
|
|
person_id: int,
|
|
update_data: PersonUpdateRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(deps.get_current_active_user),
|
|
_ = Depends(deps.RequirePermission("persons:edit")),
|
|
) -> PersonDetailResponse:
|
|
"""
|
|
PATCH /admin/persons/{person_id}
|
|
|
|
Admin általi Person szerkesztés. Csak a megadott mezők módosíthatók.
|
|
|
|
Szerkeszthető mezők:
|
|
- Személyes adatok: last_name, first_name, phone
|
|
- Születési adatok: mothers_last_name, mothers_first_name, birth_place, birth_date
|
|
- Dokumentumok: identity_docs, ice_contact
|
|
- Státusz: is_active, is_ghost
|
|
- Cím: address (nested object)
|
|
|
|
Args:
|
|
person_id: A módosítandó Person ID-ja.
|
|
update_data: A módosítandó mezők.
|
|
|
|
Returns:
|
|
PersonDetailResponse séma szerinti frissített Person adatok.
|
|
|
|
Raises:
|
|
HTTPException 404: Ha a Person nem található.
|
|
HTTPException 409: Ha a Person már merge-elve van (csak olvasható).
|
|
"""
|
|
# ── 1. Lekérdezzük a Person rekordot ──
|
|
stmt = (
|
|
select(Person)
|
|
.options(
|
|
joinedload(Person.address),
|
|
joinedload(Person.users),
|
|
joinedload(Person.memberships).joinedload(OrganizationMember.organization),
|
|
joinedload(Person.owned_business_entities),
|
|
)
|
|
.where(Person.id == person_id)
|
|
)
|
|
result = await db.execute(stmt)
|
|
person = result.unique().scalar_one_or_none()
|
|
|
|
if not person:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Person with id {person_id} not found.",
|
|
)
|
|
|
|
# ── 2. Biztonsági ellenőrzések ──
|
|
# Merge-elt Person nem szerkeszthető (csak olvasható)
|
|
if person.merged_into_id is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=(
|
|
f"Person {person_id} már merge-elve van a {person.merged_into_id} ID-jú "
|
|
"Person-ba. A merge-elt Person-ok csak olvashatóak."
|
|
),
|
|
)
|
|
|
|
# ── 3. Változások naplózása audit loghoz ──
|
|
changes: Dict[str, Dict[str, Any]] = {}
|
|
|
|
# ── 4. Person mezők frissítése ──
|
|
if update_data.last_name is not None:
|
|
changes["last_name"] = {"old": person.last_name, "new": update_data.last_name}
|
|
person.last_name = update_data.last_name
|
|
|
|
if update_data.first_name is not None:
|
|
changes["first_name"] = {"old": person.first_name, "new": update_data.first_name}
|
|
person.first_name = update_data.first_name
|
|
|
|
if update_data.phone is not None:
|
|
changes["phone"] = {"old": person.phone, "new": update_data.phone}
|
|
person.phone = update_data.phone
|
|
|
|
if update_data.mothers_last_name is not None:
|
|
changes["mothers_last_name"] = {"old": person.mothers_last_name, "new": update_data.mothers_last_name}
|
|
person.mothers_last_name = update_data.mothers_last_name
|
|
|
|
if update_data.mothers_first_name is not None:
|
|
changes["mothers_first_name"] = {"old": person.mothers_first_name, "new": update_data.mothers_first_name}
|
|
person.mothers_first_name = update_data.mothers_first_name
|
|
|
|
if update_data.birth_place is not None:
|
|
changes["birth_place"] = {"old": person.birth_place, "new": update_data.birth_place}
|
|
person.birth_place = update_data.birth_place
|
|
|
|
if update_data.birth_date is not None:
|
|
old_birth = person.birth_date.isoformat() if person.birth_date else None
|
|
changes["birth_date"] = {"old": old_birth, "new": update_data.birth_date.isoformat()}
|
|
person.birth_date = datetime.combine(update_data.birth_date, datetime.min.time())
|
|
|
|
if update_data.identity_docs is not None:
|
|
changes["identity_docs"] = {"old": person.identity_docs, "new": update_data.identity_docs}
|
|
person.identity_docs = update_data.identity_docs
|
|
|
|
if update_data.ice_contact is not None:
|
|
changes["ice_contact"] = {"old": person.ice_contact, "new": update_data.ice_contact}
|
|
person.ice_contact = update_data.ice_contact
|
|
|
|
if update_data.is_active is not None:
|
|
changes["is_active"] = {"old": person.is_active, "new": update_data.is_active}
|
|
person.is_active = update_data.is_active
|
|
|
|
if update_data.is_ghost is not None:
|
|
changes["is_ghost"] = {"old": person.is_ghost, "new": update_data.is_ghost}
|
|
person.is_ghost = update_data.is_ghost
|
|
|
|
# ── 5. Cím adatok frissítése ──
|
|
if update_data.address is not None:
|
|
addr_data = update_data.address
|
|
|
|
# Ha van már cím, frissítjük, különben létrehozzuk
|
|
if person.address:
|
|
address = person.address
|
|
else:
|
|
# Új cím létrehozása
|
|
address = Address(
|
|
created_at=datetime.utcnow()
|
|
)
|
|
db.add(address)
|
|
person.address = address
|
|
|
|
# Cím mezők frissítése
|
|
addr_changes: Dict[str, Any] = {}
|
|
if addr_data.address_zip is not None:
|
|
addr_changes["address_zip"] = {"old": address.zip, "new": addr_data.address_zip}
|
|
# Megjegyzés: a zip a GeoPostalCode kapcsolatból jön, itt csak a postal_code_id-t tudnánk keresni
|
|
# Egyszerűsítés: a frontend által küldött adatokat tároljuk a full_address_text-ben is
|
|
if addr_data.address_city is not None:
|
|
addr_changes["address_city"] = {"old": address.city, "new": addr_data.address_city}
|
|
if addr_data.address_street_name is not None:
|
|
addr_changes["address_street_name"] = {"old": address.street_name, "new": addr_data.address_street_name}
|
|
address.street_name = addr_data.address_street_name
|
|
if addr_data.address_street_type is not None:
|
|
addr_changes["address_street_type"] = {"old": address.street_type, "new": addr_data.address_street_type}
|
|
address.street_type = addr_data.address_street_type
|
|
if addr_data.address_house_number is not None:
|
|
addr_changes["address_house_number"] = {"old": address.house_number, "new": addr_data.address_house_number}
|
|
address.house_number = addr_data.address_house_number
|
|
if addr_data.address_stairwell is not None:
|
|
addr_changes["address_stairwell"] = {"old": address.stairwell, "new": addr_data.address_stairwell}
|
|
address.stairwell = addr_data.address_stairwell
|
|
if addr_data.address_floor is not None:
|
|
addr_changes["address_floor"] = {"old": address.floor, "new": addr_data.address_floor}
|
|
address.floor = addr_data.address_floor
|
|
if addr_data.address_door is not None:
|
|
addr_changes["address_door"] = {"old": address.door, "new": addr_data.address_door}
|
|
address.door = addr_data.address_door
|
|
if addr_data.address_hrsz is not None:
|
|
addr_changes["address_hrsz"] = {"old": address.parcel_id, "new": addr_data.address_hrsz}
|
|
address.parcel_id = addr_data.address_hrsz
|
|
if addr_data.full_address_text is not None:
|
|
addr_changes["full_address_text"] = {"old": address.full_address_text, "new": addr_data.full_address_text}
|
|
address.full_address_text = addr_data.full_address_text
|
|
if addr_data.latitude is not None:
|
|
addr_changes["latitude"] = {"old": address.latitude, "new": addr_data.latitude}
|
|
address.latitude = addr_data.latitude
|
|
if addr_data.longitude is not None:
|
|
addr_changes["longitude"] = {"old": address.longitude, "new": addr_data.longitude}
|
|
address.longitude = addr_data.longitude
|
|
|
|
if addr_changes:
|
|
changes["address"] = addr_changes
|
|
|
|
# ── 6. Mentés ──
|
|
if changes:
|
|
await db.flush()
|
|
await db.refresh(person)
|
|
|
|
logger.info(
|
|
f"Admin {current_user.id} ({current_user.email}) updated person "
|
|
f"person_id={person_id}: changed_fields={list(changes.keys())}"
|
|
)
|
|
else:
|
|
logger.info(
|
|
f"Admin {current_user.id} ({current_user.email}) called update on person "
|
|
f"person_id={person_id} with no changes."
|
|
)
|
|
|
|
# ── 7. Válasz összeállítása (ugyanaz, mint a GET detail) ──
|
|
# Cím adatok
|
|
address_data = None
|
|
if person.address:
|
|
address_data = AddressResponse(
|
|
address_zip=person.address.zip,
|
|
address_city=person.address.city,
|
|
address_street_name=person.address.street_name,
|
|
address_street_type=person.address.street_type,
|
|
address_house_number=person.address.house_number,
|
|
address_stairwell=person.address.stairwell,
|
|
address_floor=person.address.floor,
|
|
address_door=person.address.door,
|
|
address_hrsz=person.address.parcel_id,
|
|
full_address_text=person.address.full_address_text,
|
|
latitude=person.address.latitude,
|
|
longitude=person.address.longitude,
|
|
)
|
|
|
|
# Kapcsolódó User-ek
|
|
users_data: List[PersonDetailUserInfo] = []
|
|
if person.users:
|
|
for user in person.users:
|
|
if user.is_deleted:
|
|
continue
|
|
users_data.append(PersonDetailUserInfo(
|
|
id=user.id,
|
|
email=user.email,
|
|
role=user.role.value if hasattr(user.role, "value") else str(user.role),
|
|
role_id=user.role_id,
|
|
is_active=user.is_active,
|
|
is_deleted=user.is_deleted,
|
|
subscription_plan=user.subscription_plan,
|
|
is_vip=user.is_vip,
|
|
preferred_language=user.preferred_language,
|
|
region_code=user.region_code,
|
|
preferred_currency=user.preferred_currency,
|
|
created_at=user.created_at.isoformat() if user.created_at else None,
|
|
))
|
|
|
|
# Szervezeti tagságok
|
|
memberships_data: List[PersonDetailMembership] = []
|
|
if person.memberships:
|
|
for m in person.memberships:
|
|
org_name = m.organization.name if m.organization else None
|
|
org_type = m.organization.org_type.value if m.organization and hasattr(m.organization.org_type, "value") else (
|
|
m.organization.org_type if m.organization else None
|
|
)
|
|
memberships_data.append(PersonDetailMembership(
|
|
id=m.id,
|
|
organization_id=m.organization_id,
|
|
organization_name=org_name,
|
|
organization_type=org_type,
|
|
role=m.role if not hasattr(m.role, "value") else m.role.value,
|
|
is_verified=m.is_verified,
|
|
status=m.status,
|
|
created_at=m.created_at.isoformat() if m.created_at else None,
|
|
))
|
|
|
|
# Tulajdonolt szervezetek
|
|
owned_orgs_data: List[PersonDetailOwnedOrganization] = []
|
|
if person.owned_business_entities:
|
|
for org in person.owned_business_entities:
|
|
owned_orgs_data.append(PersonDetailOwnedOrganization(
|
|
id=org.id,
|
|
name=org.name,
|
|
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
|
is_active=org.is_active,
|
|
created_at=org.created_at.isoformat() if org.created_at else None,
|
|
))
|
|
|
|
# Merge history
|
|
merge_history_data = None
|
|
if person.merged_into_id:
|
|
target_person_stmt = select(Person).where(Person.id == person.merged_into_id)
|
|
target_result = await db.execute(target_person_stmt)
|
|
target_person = target_result.scalar_one_or_none()
|
|
target_name = None
|
|
if target_person:
|
|
target_name = f"{target_person.last_name} {target_person.first_name}".strip()
|
|
|
|
merge_history_data = PersonDetailMergeHistory(
|
|
id=person.id,
|
|
merged_into_id=person.merged_into_id,
|
|
merged_into_name=target_name,
|
|
merged_at=person.updated_at.isoformat() if person.updated_at else None,
|
|
)
|
|
|
|
return PersonDetailResponse(
|
|
id=person.id,
|
|
id_uuid=str(person.id_uuid),
|
|
last_name=person.last_name or "",
|
|
first_name=person.first_name or "",
|
|
phone=person.phone,
|
|
mothers_last_name=person.mothers_last_name,
|
|
mothers_first_name=person.mothers_first_name,
|
|
birth_place=person.birth_place,
|
|
birth_date=person.birth_date.isoformat() if person.birth_date else None,
|
|
identity_docs=person.identity_docs or {},
|
|
ice_contact=person.ice_contact or {},
|
|
is_ghost=person.is_ghost,
|
|
is_active=person.is_active,
|
|
is_sales_agent=person.is_sales_agent,
|
|
lifetime_xp=person.lifetime_xp,
|
|
penalty_points=person.penalty_points,
|
|
social_reputation=float(person.social_reputation) if person.social_reputation else 0.0,
|
|
merged_into_id=person.merged_into_id,
|
|
deleted_at=person.deleted_at.isoformat() if person.deleted_at else None,
|
|
created_at=person.created_at.isoformat() if person.created_at else None,
|
|
updated_at=person.updated_at.isoformat() if person.updated_at else None,
|
|
address=address_data,
|
|
users=users_data,
|
|
memberships=memberships_data,
|
|
owned_organizations=owned_orgs_data,
|
|
merge_history=merge_history_data,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# POST /{person_id}/merge — Person merge (duplum kezelés)
|
|
# =============================================================================
|
|
|
|
|
|
class PersonMergeRequest(BaseModel):
|
|
"""Person merge kérés body.
|
|
|
|
A logic_spec 4.7-es szekciója szerint:
|
|
- source_person_id: A forrás Person ID-ja (amit beolvasztunk)
|
|
- keep_source_user: Ha True, a forrás User megmarad (különben soft-delete)
|
|
"""
|
|
source_person_id: int = Field(..., description="A forrás Person ID-ja (amit beolvasztunk a célba)")
|
|
keep_source_user: bool = Field(
|
|
default=False,
|
|
description="Ha True, a forrás User-ek nem lesznek soft-deletelve, csak átirányítva"
|
|
)
|
|
|
|
|
|
class PersonMergeResponse(BaseModel):
|
|
"""Person merge válasz."""
|
|
success: bool = True
|
|
message: str
|
|
target_person_id: int
|
|
source_person_id: int
|
|
reassigned_users: int = 0
|
|
reassigned_memberships: int = 0
|
|
|
|
|
|
@router.post(
|
|
"/{person_id}/merge",
|
|
response_model=PersonMergeResponse,
|
|
summary="Person merge (duplum kezelés)",
|
|
description=(
|
|
"Két Person rekord összefésülése. A forrás Person (source_person_id) "
|
|
"beolvasztásra kerül a cél Person-ba (person_id). A forrás Person "
|
|
"merged_into_id mezője beállításra kerül, User-ek és OrganizationMember-ek "
|
|
"átkerülnek a cél Person-hoz. Audit log bejegyzés készül."
|
|
),
|
|
)
|
|
async def merge_person(
|
|
person_id: int,
|
|
merge_data: PersonMergeRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(deps.get_current_active_user),
|
|
_ = Depends(deps.RequirePermission("persons:merge")),
|
|
) -> PersonMergeResponse:
|
|
"""
|
|
POST /admin/persons/{person_id}/merge
|
|
|
|
Két Person rekord összefésülése (duplum kezelés).
|
|
|
|
Logika (logic_spec 4.7):
|
|
1. Ellenőrizzük, hogy mindkét Person létezik és nincs már merge-elve
|
|
2. Forrás Person `merged_into_id` → cél Person ID
|
|
3. Forrás User-ek `person_id` → cél Person ID
|
|
4. Forrás OrganizationMember-ek `person_id` → cél Person ID
|
|
5. Audit log bejegyzés
|
|
|
|
Args:
|
|
person_id: A cél Person ID-ja (amibe beolvasztunk).
|
|
merge_data: A merge kérés adatai (source_person_id, keep_source_user).
|
|
|
|
Returns:
|
|
PersonMergeResponse a merge eredményével.
|
|
|
|
Raises:
|
|
HTTPException 404: Ha valamelyik Person nem található.
|
|
HTTPException 409: Ha valamelyik Person már merge-elve van,
|
|
vagy ha a forrás és cél megegyezik.
|
|
"""
|
|
# ── 1. Validáció ──
|
|
# Nem lehet önmagába merge-elni
|
|
if merge_data.source_person_id == person_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="A forrás és a cél Person nem lehet ugyanaz.",
|
|
)
|
|
|
|
# Cél Person lekérdezése
|
|
target_stmt = select(Person).where(Person.id == person_id)
|
|
target_result = await db.execute(target_stmt)
|
|
target_person = target_result.scalar_one_or_none()
|
|
|
|
if not target_person:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"A cél Person (id={person_id}) nem található.",
|
|
)
|
|
|
|
# Forrás Person lekérdezése
|
|
source_stmt = (
|
|
select(Person)
|
|
.options(
|
|
joinedload(Person.users),
|
|
joinedload(Person.memberships),
|
|
)
|
|
.where(Person.id == merge_data.source_person_id)
|
|
)
|
|
source_result = await db.execute(source_stmt)
|
|
source_person = source_result.unique().scalar_one_or_none()
|
|
|
|
if not source_person:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"A forrás Person (id={merge_data.source_person_id}) nem található.",
|
|
)
|
|
|
|
# Ellenőrizzük, hogy a cél Person nincs-e már merge-elve
|
|
if target_person.merged_into_id is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=(
|
|
f"A cél Person (id={person_id}) már merge-elve van a "
|
|
f"{target_person.merged_into_id} ID-jú Person-ba. "
|
|
"Csak nem merge-elt Person lehet merge célpont."
|
|
),
|
|
)
|
|
|
|
# Ellenőrizzük, hogy a forrás Person nincs-e már merge-elve
|
|
if source_person.merged_into_id is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=(
|
|
f"A forrás Person (id={merge_data.source_person_id}) már merge-elve van a "
|
|
f"{source_person.merged_into_id} ID-jú Person-ba. "
|
|
"Csak nem merge-elt Person lehet merge forrás."
|
|
),
|
|
)
|
|
|
|
# ── 2. Forrás Person megjelölése merge-eltként ──
|
|
source_person.merged_into_id = person_id
|
|
source_person.is_active = False
|
|
|
|
# ── 3. User-ek átirányítása a cél Person-hoz ──
|
|
reassigned_users = 0
|
|
if source_person.users:
|
|
for user in source_person.users:
|
|
if user.is_deleted:
|
|
continue
|
|
user.person_id = person_id
|
|
reassigned_users += 1
|
|
|
|
# Ha keep_source_user False, soft-delete-eljük a forrás User-t
|
|
if not merge_data.keep_source_user:
|
|
user.is_deleted = True
|
|
user.deleted_at = datetime.utcnow()
|
|
|
|
# ── 4. OrganizationMember-ek átirányítása a cél Person-hoz ──
|
|
reassigned_memberships = 0
|
|
if source_person.memberships:
|
|
for membership in source_person.memberships:
|
|
membership.person_id = person_id
|
|
reassigned_memberships += 1
|
|
|
|
# ── 5. Mentés ──
|
|
await db.flush()
|
|
|
|
# ── 6. Audit log ──
|
|
audit_entry = AuditLog(
|
|
user_id=current_user.id,
|
|
severity=LogSeverity.info,
|
|
action="person.merge",
|
|
target_type="Person",
|
|
target_id=str(person_id),
|
|
old_data={
|
|
"source_person_id": merge_data.source_person_id,
|
|
"source_person_name": f"{source_person.last_name} {source_person.first_name}".strip(),
|
|
},
|
|
new_data={
|
|
"target_person_id": person_id,
|
|
"target_person_name": f"{target_person.last_name} {target_person.first_name}".strip(),
|
|
"reassigned_users": reassigned_users,
|
|
"reassigned_memberships": reassigned_memberships,
|
|
"keep_source_user": merge_data.keep_source_user,
|
|
},
|
|
)
|
|
db.add(audit_entry)
|
|
|
|
await db.commit()
|
|
|
|
logger.info(
|
|
f"Admin {current_user.id} ({current_user.email}) merged person "
|
|
f"source_person_id={merge_data.source_person_id} into "
|
|
f"target_person_id={person_id}: "
|
|
f"users={reassigned_users}, memberships={reassigned_memberships}"
|
|
)
|
|
|
|
return PersonMergeResponse(
|
|
success=True,
|
|
message=(
|
|
f"A {source_person.last_name} {source_person.first_name} (ID: {merge_data.source_person_id}) "
|
|
f"sikeresen beolvasztásra került a {target_person.last_name} {target_person.first_name} "
|
|
f"(ID: {person_id}) Person-ba."
|
|
),
|
|
target_person_id=person_id,
|
|
source_person_id=merge_data.source_person_id,
|
|
reassigned_users=reassigned_users,
|
|
reassigned_memberships=reassigned_memberships,
|
|
)
|