""" 👤 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.address import AddressIn, AddressOut from app.services.address_manager import AddressManager 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[AddressOut] = 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 = AddressOut.model_validate(person.address) 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[AddressOut] = 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 = AddressOut.model_validate(person.address) # ── 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 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) — egységes AddressIn séma address: Optional[AddressIn] = 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 (P1 REFACTORED: AddressManager) ── if update_data.address is not None: # P1 FIX: Use AddressManager.create_or_update() instead of # writing directly to Address model fields. person.address_id = await AddressManager.create_or_update( db, person.address_id, update_data.address ) changes["address"] = {"old": None, "new": "updated via AddressManager"} # ── 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 = AddressOut.model_validate(person.address) # 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, )