Files
service-finder/backend/app/api/v1/endpoints/admin_persons.py
2026-06-29 14:11:15 +00:00

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,
)