""" 🏛️ Admin Provider Moderation API Végpontok a ServiceProvider és ServiceStaging rekordok adminisztrációjához és moderálásához. Minden végponthoz `providers:moderate` permission szükséges. Végpontok: GET /admin/providers — Szolgáltatók listázása szűréssel (UNION ALL: providers + staging) GET /admin/providers/stats — Statisztikák (pending count, total, etc.) GET /admin/providers/{id} — Részletes adatok GET /admin/providers/{id}/history — Provider audit előzmények POST /admin/providers/{id}/approve — Jóváhagyás (pending -> approved) + Gamification XP POST /admin/providers/{id}/reject — Elutasítás (pending -> rejected) + UserContribution POST /admin/providers/{id}/flag — Megjelölés (approved -> flagged) POST /admin/providers/{id}/restore — Visszaállítás (rejected -> approved) DELETE /admin/providers/{id} — Törlés (soft delete) """ from __future__ import annotations import logging from datetime import datetime, timezone from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy import select, func, or_, union_all, literal, cast, String, case from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.api import deps from app.schemas.address import AddressIn, AddressOut from app.db.session import get_db from app.models.identity import User from app.models.identity.social import ( ServiceProvider, ModerationStatus, SourceType, ProviderValidation, ) from app.models.marketplace.service import ServiceProfile from app.models.marketplace.staged_data import ServiceStaging from app.models.gamification.gamification import UserContribution from app.models.vehicle.history import AuditLog from app.services.gamification_service import gamification_service from app.services.provider_service import _sync_service_expertises logger = logging.getLogger("admin-providers") router = APIRouter() # ============================================================================= # Pydantic Schemas # ============================================================================= class ProviderListItem(BaseModel): """Egy szolgáltató rövid adatai a listázáshoz.""" id: int name: str address: Optional[str] = None city: Optional[str] = None category: Optional[str] = None status: str source: str source_table: str = "service_providers" # Melyik táblából jött: service_providers | service_staging | organizations validation_score: int = 0 added_by_user_id: Optional[int] = None created_at: Optional[datetime] = None model_config = {"from_attributes": True} class ProviderDetail(BaseModel): """Szolgáltató részletes adatai.""" id: int name: str address: str city: Optional[str] = None address_detail: Optional[AddressOut] = None plus_code: Optional[str] = None contact_phone: Optional[str] = None contact_email: Optional[str] = None website: Optional[str] = None category: Optional[str] = None description: Optional[str] = None status: str source: str source_table: str = "service_providers" validation_score: int = 0 trust_score: Optional[int] = None rejection_reason: Optional[str] = None raw_data: Optional[Dict[str, Any]] = None audit_trail: Optional[Dict[str, Any]] = None evidence_image_path: Optional[str] = None added_by_user_id: Optional[int] = None category_ids: Optional[List[int]] = None supported_vehicle_classes: Optional[List[str]] = None specializations: Optional[dict] = None opening_hours: Optional[dict] = None created_at: Optional[datetime] = None model_config = {"from_attributes": True} class ProviderStats(BaseModel): """Provider statisztikák.""" total: int = 0 pending: int = 0 approved: int = 0 rejected: int = 0 flagged: int = 0 total_validations: int = 0 staging_pending: int = 0 # ServiceStaging research_in_progress rekordok class ModerationAction(BaseModel): """Moderációs akció bemeneti adatai.""" reason: Optional[str] = Field(None, max_length=500, description="Indoklás a moderációs akcióhoz") metadata: Optional[Dict[str, Any]] = Field(None, description="További metaadatok (JSON)") class ModerationResponse(BaseModel): """Moderációs akció válasza.""" id: int name: str status: str message: str validation_score: int = 0 class ProviderUpdateInput(BaseModel): """Provider adatok szerkesztésének bemeneti sémája.""" name: Optional[str] = Field(None, min_length=1, max_length=255, description="Szolgáltató neve") address: Optional[str] = Field(None, max_length=500, description="Teljes cím") city: Optional[str] = Field(None, max_length=100, description="Város") address_detail: Optional[AddressIn] = Field(None, description="Cím részletes adatai (egységes AddressIn séma)") plus_code: Optional[str] = Field(None, max_length=50, description="Plus Code (Google Maps)") contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám") contact_email: Optional[str] = Field(None, max_length=255, description="Email cím") website: Optional[str] = Field(None, max_length=500, description="Weboldal URL") category: Optional[str] = Field(None, max_length=100, description="Kategória") status: Optional[str] = Field(None, pattern=r"^(pending|approved|rejected)$", description="Státusz módosítása (pending/approved/rejected)") validation_score: Optional[int] = Field(None, ge=0, le=100, description="Validációs pontszám (0-100)") category_ids: Optional[List[int]] = Field(None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)") supported_vehicle_classes: Optional[List[str]] = Field(None, description="Támogatott járműosztályok (pl. ['car', 'hgv', 'van'])") specializations: Optional[dict] = Field(None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})") opening_hours: Optional[dict] = Field(None, description="Nyitvatartás JSONB (7 nap, minden nap: {open, close} vagy null)") is_always_open: Optional[bool] = Field(None, description="Folyamatos (0-24) nyitvatartás jelzője") class ProviderHistoryEntry(BaseModel): """Egy audit log bejegyzés a provider history nézethez.""" id: int action: str user_id: Optional[int] = None old_data: Optional[Dict[str, Any]] = None new_data: Optional[Dict[str, Any]] = None timestamp: Optional[datetime] = None model_config = {"from_attributes": True} class ProviderValidationEntry(BaseModel): """Egy gamification validációs bejegyzés a providerhez.""" id: int provider_id: int voter_user_id: int validation_type: str weight: int = 1 validation_metadata: Optional[Dict[str, Any]] = None created_at: Optional[datetime] = None voter_name: Optional[str] = None # A szavazó user neve model_config = {"from_attributes": True} # ============================================================================= # Helper functions # ============================================================================= async def _log_moderation_action( db: AsyncSession, user_id: int, action: str, provider_id: int, reason: Optional[str] = None, old_status: Optional[str] = None, new_status: Optional[str] = None, source_table: str = "service_providers", ) -> None: """Moderációs akció naplózása az AuditLog táblába.""" log_entry = AuditLog( user_id=user_id, action=action, target_type="service_provider", target_id=str(provider_id), new_data={ "reason": reason, "old_status": old_status, "new_status": new_status, "source_table": source_table, "timestamp": datetime.now(timezone.utc).isoformat(), }, ) db.add(log_entry) await db.flush() async def _create_provider_validation( db: AsyncSession, provider_id: int, voter_user_id: int, validation_type: str, weight: int = 1, metadata: Optional[Dict[str, Any]] = None, ) -> ProviderValidation: """ Provider validációs rekord UPSERT-je. Ha már létezik validáció ugyanerre a (voter_user_id, provider_id) párra, UPDATE-eli az action-t és a timestamp-et ahelyett, hogy duplikációt okozna. """ from sqlalchemy.dialects.postgresql import insert as pg_insert stmt = pg_insert(ProviderValidation).values( provider_id=provider_id, voter_user_id=voter_user_id, validation_type=validation_type, weight=weight, validation_metadata=metadata or {}, ) stmt = stmt.on_conflict_do_update( constraint="uq_voter_provider_validation", set_={ "validation_type": validation_type, "weight": weight, "validation_metadata": metadata or {}, }, ) await db.execute(stmt) # Vissza kell töltenünk a rekordot, hogy a hívó használni tudja result = await db.execute( select(ProviderValidation).where( ProviderValidation.provider_id == provider_id, ProviderValidation.voter_user_id == voter_user_id, ) ) validation = result.scalar_one() return validation async def _get_provider_or_404(db: AsyncSession, provider_id: int) -> ServiceProvider: """Provider lekérése ID alapján, 404 hibával ha nem létezik.""" result = await db.execute( select(ServiceProvider).where(ServiceProvider.id == provider_id) ) provider = result.scalar_one_or_none() if not provider: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Provider with ID {provider_id} not found.", ) return provider async def _get_staging_or_404(db: AsyncSession, staging_id: int) -> ServiceStaging: """ServiceStaging rekord lekérése ID alapján, 404 hibával ha nem létezik.""" result = await db.execute( select(ServiceStaging).where(ServiceStaging.id == staging_id) ) staging = result.scalar_one_or_none() if not staging: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"ServiceStaging record with ID {staging_id} not found.", ) return staging def _build_unified_providers_query( status_filter: Optional[str] = None, source_filter: Optional[str] = None, search: Optional[str] = None, source_table_filter: Optional[str] = None, ): """ Egyesített lekérdezés a ServiceProvider, ServiceStaging és Organizations táblákból. A source_filter és source_table_filter szűréseket a UNION ALL eredményére alkalmazzuk (nem az egyes részekre), hogy elkerüljük az enum típusütközéseket. """ from app.models.marketplace.organization import Organization from app.models.identity.address import Address, GeoPostalCode # --- 1. ServiceProvider lekérdezés --- provider_conditions = [] if status_filter: provider_conditions.append(ServiceProvider.status == status_filter) if search: provider_conditions.append(ServiceProvider.name.ilike(f"%{search}%")) provider_stmt = select( ServiceProvider.id.label("id"), ServiceProvider.name.label("name"), ServiceProvider.address.label("address"), ServiceProvider.city.label("city"), ServiceProvider.category.label("category"), cast(ServiceProvider.status, String).label("status"), cast(ServiceProvider.source, String).label("source"), literal("service_providers").label("source_table"), ServiceProvider.validation_score.label("validation_score"), ServiceProvider.added_by_user_id.label("added_by_user_id"), ServiceProvider.created_at.label("created_at"), ) if provider_conditions: provider_stmt = provider_stmt.where(*provider_conditions) # --- 2. ServiceStaging lekérdezés --- staging_conditions = [] if status_filter == "pending": staging_conditions.append(ServiceStaging.status == "research_in_progress") elif status_filter == "approved": staging_conditions.append(ServiceStaging.status == "auditing") elif status_filter == "rejected": staging_conditions.append(ServiceStaging.status == "no_web_presence") elif status_filter: staging_conditions.append(ServiceStaging.status == status_filter) if search: staging_conditions.append(ServiceStaging.name.ilike(f"%{search}%")) staging_stmt = select( ServiceStaging.id.label("id"), ServiceStaging.name.label("name"), ServiceStaging.full_address.label("address"), ServiceStaging.city.label("city"), literal(None).label("category"), ServiceStaging.status.label("status"), literal("bot").label("source"), literal("service_staging").label("source_table"), literal(0).label("validation_score"), literal(None).label("added_by_user_id"), ServiceStaging.created_at.label("created_at"), ) if staging_conditions: staging_stmt = staging_stmt.where(*staging_conditions) # --- 3. Organizations lekérdezés (service_provider típusúak) --- org_conditions = [ Organization.is_deleted == False, Organization.org_type == "service_provider", ] if search: org_conditions.append(Organization.name.ilike(f"%{search}%")) org_stmt = select( Organization.id.label("id"), Organization.name.label("name"), func.concat( GeoPostalCode.zip_code, literal(' '), GeoPostalCode.city, literal(', '), Address.street_name, literal(' '), Address.street_type, literal(' '), Address.house_number, ).label("address"), GeoPostalCode.city.label("city"), literal(None).label("category"), literal("approved").label("status"), literal("verified_org").label("source"), literal("organizations").label("source_table"), literal(100).label("validation_score"), literal(None).label("added_by_user_id"), Organization.created_at.label("created_at"), ).outerjoin( Address, Address.id == Organization.address_id, ).outerjoin( GeoPostalCode, GeoPostalCode.id == Address.postal_code_id, ) if org_conditions: org_stmt = org_stmt.where(*org_conditions) # --- UNION ALL --- base_query = union_all(provider_stmt, staging_stmt, org_stmt).alias("unified_providers") # --- Szűrések a UNION ALL eredményére --- # A source_filter és source_table_filter szűréseket itt, a végeredményen # alkalmazzuk, mert a 'source' oszlop már String típusú a CAST miatt, # így a "bot" és "verified_org" értékek is működnek. final_conditions = [] if source_filter: final_conditions.append(base_query.c.source == source_filter) if source_table_filter: final_conditions.append(base_query.c.source_table == source_table_filter) if final_conditions: inner = base_query base_query = select(inner).where(*final_conditions).alias("unified_providers") return base_query # ============================================================================= # Endpoints # ============================================================================= @router.get("", response_model=List[ProviderListItem]) async def list_providers( 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"), status_filter: Optional[str] = Query(None, alias="status", description="Szűrés státuszra (pending/approved/rejected/flagged)"), source_filter: Optional[str] = Query(None, alias="source", description="Szűrés forrásra (manual/ocr/import/bot/verified_org)"), source_table: Optional[str] = Query(None, alias="source_table", description="Szűrés táblára (service_providers/service_staging/organizations)"), search: Optional[str] = Query(None, min_length=2, description="Keresés név alapján (ILIKE)"), sort_by: str = Query("created_at", description="Rendezés mezője"), sort_order: str = Query("desc", description="Rendezés iránya (asc/desc)"), db: AsyncSession = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("providers:moderate")), ): """ Szolgáltatók listázása szűréssel, kereséssel és lapozással. Három forrásból egyesít: service_providers, service_staging, organizations. """ base_query = _build_unified_providers_query( status_filter=status_filter, source_filter=source_filter, search=search, source_table_filter=source_table, ) # Teljes darabszám count_stmt = select(func.count()).select_from(base_query) total_result = await db.execute(count_stmt) total = total_result.scalar() or 0 # Rendezés allowed_sort_fields = { "created_at": "created_at", "name": "name", "validation_score": "validation_score", "status": "status", "source": "source", } sort_column_name = allowed_sort_fields.get(sort_by, "created_at") sort_col = base_query.c[sort_column_name] # Lapozott lekérdezés paginated_query = ( select(base_query) .order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc()) .offset(skip) .limit(limit) ) result = await db.execute(paginated_query) rows = result.fetchall() items = [] for row in rows: items.append(ProviderListItem( id=row.id, name=row.name, address=row.address, city=row.city, category=row.category, status=row.status, source=row.source, source_table=row.source_table, validation_score=row.validation_score or 0, added_by_user_id=row.added_by_user_id, created_at=row.created_at, )) return items @router.get("/stats", response_model=ProviderStats) async def get_provider_stats( db: AsyncSession = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("providers:moderate")), ): """ Provider statisztikák lekérése. Tartalmazza a ServiceStaging rekordokat is. """ # ServiceProvider darabszámok total_result = await db.execute(select(func.count()).select_from(select(ServiceProvider).subquery())) total_providers = total_result.scalar() or 0 status_counts = {} for status_value in ["pending", "approved", "rejected"]: count_result = await db.execute( select(func.count()).where(ServiceProvider.status == status_value) ) status_counts[status_value] = count_result.scalar() or 0 # ServiceStaging darabszámok staging_total_result = await db.execute( select(func.count()).select_from(select(ServiceStaging).subquery()) ) total_staging = staging_total_result.scalar() or 0 staging_pending_result = await db.execute( select(func.count()).where(ServiceStaging.status == "research_in_progress") ) staging_pending = staging_pending_result.scalar() or 0 # Validációk száma validations_result = await db.execute( select(func.count()).select_from(select(ProviderValidation).subquery()) ) total_validations = validations_result.scalar() or 0 return ProviderStats( total=total_providers + total_staging, pending=status_counts.get("pending", 0) + staging_pending, approved=status_counts.get("approved", 0), rejected=status_counts.get("rejected", 0), flagged=status_counts.get("flagged", 0), total_validations=total_validations, staging_pending=staging_pending, ) @router.get("/{provider_id}", response_model=ProviderDetail) async def get_provider_detail( provider_id: int, db: AsyncSession = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("providers:moderate")), ): """ Szolgáltató részletes adatainak lekérése. Először a ServiceProvider táblában keres, ha nem találja, a ServiceStaging-ben. """ from app.models.marketplace.service import ServiceExpertise, ExpertiseTag # Próbáljuk ServiceProvider-ben provider = await db.execute( select(ServiceProvider).where(ServiceProvider.id == provider_id) ) sp = provider.scalar_one_or_none() if sp: # Lekérdezzük a kapcsolódó kategória ID-kat a ServiceProfile-on keresztül resolved_category_ids: Optional[List[int]] = None profile_stmt = select(ServiceProfile).where( ServiceProfile.service_provider_id == provider_id ) profile_result = await db.execute(profile_stmt) profile = profile_result.scalar_one_or_none() if profile: expertise_stmt = select(ServiceExpertise.expertise_id).where( ServiceExpertise.service_id == profile.id ) expertise_result = await db.execute(expertise_stmt) resolved_category_ids = [row[0] for row in expertise_result.fetchall()] # Get opening_hours from the ServiceProfile (JSONB column) opening_hours = profile.opening_hours if profile else {} return ProviderDetail( id=sp.id, name=sp.name, address=sp.address or "", city=sp.city, address_detail=None, plus_code=sp.plus_code, contact_phone=sp.contact_phone, contact_email=sp.contact_email, website=sp.website, category=sp.category, status=sp.status.value if hasattr(sp.status, 'value') else str(sp.status), source=sp.source.value if hasattr(sp.source, 'value') else str(sp.source), source_table="service_providers", validation_score=sp.validation_score or 0, evidence_image_path=sp.evidence_image_path, added_by_user_id=sp.added_by_user_id, category_ids=resolved_category_ids, supported_vehicle_classes=sp.supported_vehicle_classes or [], specializations=sp.specializations or {}, opening_hours=opening_hours or {}, # P0 BUGFIX: Include raw_data for ServiceProvider records so the frontend # "Nyers adatok / Crawler" tab shows actual data instead of empty/mock state raw_data=sp.raw_data or {}, created_at=sp.created_at, ) # Próbáljuk ServiceStaging-ben staging = await db.execute( select(ServiceStaging).where(ServiceStaging.id == provider_id) ) ss = staging.scalar_one_or_none() if ss: return ProviderDetail( id=ss.id, name=ss.name, address=ss.full_address or "", city=ss.city, contact_phone=ss.contact_phone, contact_email=ss.contact_email, website=ss.website, description=ss.description, status=ss.status, source=ss.source or "bot", source_table="service_staging", # BUGFIX: Use trust_score from staging as validation_score instead of hardcoded 0 # The staging table doesn't have a separate validation_score column, # but trust_score reflects the bot's confidence in the data quality validation_score=ss.trust_score or 0, trust_score=ss.trust_score, rejection_reason=ss.rejection_reason, raw_data=ss.raw_data or {}, audit_trail=ss.audit_trail or {}, created_at=ss.created_at, ) raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Provider with ID {provider_id} not found in any table.", ) @router.post("/{provider_id}/approve", response_model=ModerationResponse) async def approve_provider( provider_id: int, action: ModerationAction, db: AsyncSession = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("providers:moderate")), ): """ Szolgáltató jóváhagyása (pending -> approved). - validation_score += 50 - ProviderValidation rekord létrehozása - AuditLog bejegyzés - Gamification XP award a beküldőnek (ADD_NEW_PROVIDER) - UserContribution rekord frissítése (status -> approved) """ provider = await _get_provider_or_404(db, provider_id) if provider.status != ModerationStatus.pending: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Provider is not in pending status. Current status: {provider.status.value}", ) old_status = provider.status.value provider.status = ModerationStatus.approved provider.validation_score = (provider.validation_score or 0) + 50 # ProviderValidation rekord létrehozása await _create_provider_validation( db=db, provider_id=provider_id, voter_user_id=current_user.id, validation_type="approve", weight=5, metadata={"reason": action.reason, **(action.metadata or {})}, ) # AuditLog await _log_moderation_action( db=db, user_id=current_user.id, action="PROVIDER_APPROVED", provider_id=provider_id, reason=action.reason, old_status=old_status, new_status="approved", ) # === GAMIFICATION XP AWARD a beküldőnek === if provider.added_by_user_id: try: await gamification_service.process_activity( db=db, user_id=provider.added_by_user_id, xp_amount=0, social_amount=0, reason="Provider approved by admin", commit=False, action_key="ADD_NEW_PROVIDER", source_type="provider_approval", source_id=provider_id, ) logger.info( f"Gamification XP awarded to user {provider.added_by_user_id} " f"for provider #{provider_id} approval (action_key=ADD_NEW_PROVIDER)" ) except Exception as e: logger.error( f"Failed to award gamification XP for provider #{provider_id} " f"approval to user {provider.added_by_user_id}: {e}" ) # === USERCONTRIBUTION frissítése === if provider.added_by_user_id: try: contrib_stmt = select(UserContribution).where( UserContribution.user_id == provider.added_by_user_id, UserContribution.entity_type == "service_provider", UserContribution.entity_id == provider_id, ).order_by(UserContribution.created_at.desc()) contrib_result = await db.execute(contrib_stmt) contribution = contrib_result.scalar_one_or_none() if contribution: contribution.status = "approved" contribution.reviewed_by = current_user.id contribution.reviewed_at = datetime.now(timezone.utc) existing_fields = dict(contribution.provided_fields or {}) existing_fields["moderation"] = { "action": "approve", "reviewed_by": current_user.id, "reviewed_at": datetime.now(timezone.utc).isoformat(), "reason": action.reason, } contribution.provided_fields = existing_fields logger.info( f"UserContribution #{contribution.id} updated to approved " f"for provider #{provider_id}" ) else: logger.warning( f"No UserContribution found for user {provider.added_by_user_id}, " f"provider #{provider_id}" ) except Exception as e: logger.error( f"Failed to update UserContribution for provider #{provider_id}: {e}" ) await db.commit() await db.refresh(provider) return ModerationResponse( id=provider.id, name=provider.name, status=provider.status.value, message="Provider successfully approved.", validation_score=provider.validation_score, ) @router.post("/{provider_id}/reject", response_model=ModerationResponse) async def reject_provider( provider_id: int, action: ModerationAction, db: AsyncSession = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("providers:moderate")), ): """ Szolgáltató elutasítása (pending -> rejected). - validation_score = 0 - ProviderValidation rekord létrehozása - AuditLog bejegyzés - UserContribution rekord frissítése (status -> rejected, rejection_reason) """ provider = await _get_provider_or_404(db, provider_id) if provider.status != ModerationStatus.pending: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Provider is not in pending status. Current status: {provider.status.value}", ) old_status = provider.status.value provider.status = ModerationStatus.rejected provider.validation_score = 0 # ProviderValidation rekord létrehozása await _create_provider_validation( db=db, provider_id=provider_id, voter_user_id=current_user.id, validation_type="reject", weight=5, metadata={"reason": action.reason, **(action.metadata or {})}, ) # AuditLog await _log_moderation_action( db=db, user_id=current_user.id, action="PROVIDER_REJECTED", provider_id=provider_id, reason=action.reason, old_status=old_status, new_status="rejected", ) # === USERCONTRIBUTION frissítése === if provider.added_by_user_id: try: contrib_stmt = select(UserContribution).where( UserContribution.user_id == provider.added_by_user_id, UserContribution.entity_type == "service_provider", UserContribution.entity_id == provider_id, ).order_by(UserContribution.created_at.desc()) contrib_result = await db.execute(contrib_stmt) contribution = contrib_result.scalar_one_or_none() if contribution: contribution.status = "rejected" contribution.reviewed_by = current_user.id contribution.reviewed_at = datetime.now(timezone.utc) existing_fields = dict(contribution.provided_fields or {}) existing_fields["moderation"] = { "action": "reject", "reviewed_by": current_user.id, "reviewed_at": datetime.now(timezone.utc).isoformat(), "reason": action.reason, "rejection_reason": action.reason, } contribution.provided_fields = existing_fields logger.info( f"UserContribution #{contribution.id} updated to rejected " f"for provider #{provider_id}" ) else: logger.warning( f"No UserContribution found for user {provider.added_by_user_id}, " f"provider #{provider_id}" ) except Exception as e: logger.error( f"Failed to update UserContribution for provider #{provider_id}: {e}" ) await db.commit() await db.refresh(provider) return ModerationResponse( id=provider.id, name=provider.name, status=provider.status.value, message="Provider rejected.", validation_score=provider.validation_score, ) @router.post("/{provider_id}/restore", response_model=ModerationResponse) async def restore_provider( provider_id: int, action: ModerationAction, db: AsyncSession = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("providers:moderate")), ): """ Elutasított szolgáltató visszaállítása (rejected -> approved). - validation_score = 50 (alap visszaállítási érték) - ProviderValidation rekord létrehozása - AuditLog bejegyzés - UserContribution rekord frissítése (status -> approved) """ provider = await _get_provider_or_404(db, provider_id) if provider.status != ModerationStatus.rejected: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Provider is not in rejected status. Current status: {provider.status.value}. Only rejected providers can be restored.", ) old_status = provider.status.value provider.status = ModerationStatus.approved provider.validation_score = 50 # Alap visszaállítási érték # ProviderValidation rekord létrehozása await _create_provider_validation( db=db, provider_id=provider_id, voter_user_id=current_user.id, validation_type="restore", weight=5, metadata={"reason": action.reason, **(action.metadata or {})}, ) # AuditLog await _log_moderation_action( db=db, user_id=current_user.id, action="PROVIDER_RESTORED", provider_id=provider_id, reason=action.reason, old_status=old_status, new_status="approved", ) # === USERCONTRIBUTION frissítése === if provider.added_by_user_id: try: contrib_stmt = select(UserContribution).where( UserContribution.user_id == provider.added_by_user_id, UserContribution.entity_type == "service_provider", UserContribution.entity_id == provider_id, ).order_by(UserContribution.created_at.desc()) contrib_result = await db.execute(contrib_stmt) contribution = contrib_result.scalar_one_or_none() if contribution: contribution.status = "approved" contribution.reviewed_by = current_user.id contribution.reviewed_at = datetime.now(timezone.utc) existing_fields = dict(contribution.provided_fields or {}) existing_fields["moderation"] = { "action": "restore", "reviewed_by": current_user.id, "reviewed_at": datetime.now(timezone.utc).isoformat(), "reason": action.reason, } contribution.provided_fields = existing_fields logger.info( f"UserContribution #{contribution.id} updated to approved " f"for provider #{provider_id} (restore)" ) else: logger.warning( f"No UserContribution found for user {provider.added_by_user_id}, " f"provider #{provider_id}" ) except Exception as e: logger.error( f"Failed to update UserContribution for provider #{provider_id}: {e}" ) await db.commit() await db.refresh(provider) return ModerationResponse( id=provider.id, name=provider.name, status=provider.status.value, message="Provider successfully restored from rejected to approved.", validation_score=provider.validation_score, ) @router.post("/{provider_id}/flag", response_model=ModerationResponse) async def flag_provider( provider_id: int, action: ModerationAction, db: AsyncSession = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("providers:moderate")), ): """ Szolgáltató megjelölése (approved -> flagged). - validation_score -= 20 (minimum 0) - ProviderValidation rekord létrehozása - AuditLog bejegyzés """ provider = await _get_provider_or_404(db, provider_id) if provider.status != ModerationStatus.approved: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Provider is not in approved status. Current status: {provider.status.value}", ) old_status = provider.status.value provider.status = ModerationStatus.rejected # flagged = rejected provider.validation_score = max(0, (provider.validation_score or 0) - 20) # ProviderValidation rekord létrehozása await _create_provider_validation( db=db, provider_id=provider_id, voter_user_id=current_user.id, validation_type="flag", weight=5, metadata={"reason": action.reason, **(action.metadata or {})}, ) # AuditLog await _log_moderation_action( db=db, user_id=current_user.id, action="PROVIDER_FLAGGED", provider_id=provider_id, reason=action.reason, old_status=old_status, new_status="flagged", ) await db.commit() await db.refresh(provider) return ModerationResponse( id=provider.id, name=provider.name, status=provider.status.value, message="Provider flagged and set to rejected.", validation_score=provider.validation_score, ) @router.delete("/{provider_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_provider( provider_id: int, db: AsyncSession = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("providers:moderate")), ): """ Szolgáltató törlése (soft delete). A rekord nem kerül ténylegesen törlésre, csak a státusza változik 'rejected'-re és a validation_score nullázódik. """ provider = await _get_provider_or_404(db, provider_id) old_status = provider.status.value provider.status = ModerationStatus.rejected provider.validation_score = 0 # AuditLog await _log_moderation_action( db=db, user_id=current_user.id, action="PROVIDER_DELETED", provider_id=provider_id, reason="Soft delete by admin", old_status=old_status, new_status="rejected", ) await db.commit() return None @router.get("/{provider_id}/validations", response_model=List[ProviderValidationEntry]) async def get_provider_validations( provider_id: int, db: AsyncSession = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("providers:moderate")), ): """ Provider gamification validációs bejegyzéseinek lekérése. Visszaadja a ProviderValidation rekordokat a szavazó user nevével együtt. """ from app.models.identity import User as UserModel result = await db.execute( select(ProviderValidation) .where(ProviderValidation.provider_id == provider_id) .order_by(ProviderValidation.created_at.desc()) .limit(100) ) validations = result.scalars().all() entries = [] for v in validations: voter_name = None if v.voter: voter_name = v.voter.email or f"User #{v.voter_user_id}" entries.append(ProviderValidationEntry( id=v.id, provider_id=v.provider_id, voter_user_id=v.voter_user_id, validation_type=v.validation_type, weight=v.weight, validation_metadata=v.validation_metadata, created_at=v.created_at, voter_name=voter_name, )) return entries @router.get("/{provider_id}/history", response_model=List[ProviderHistoryEntry]) async def get_provider_history( provider_id: int, db: AsyncSession = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("providers:moderate")), ): """ Provider audit előzményeinek lekérése. Visszaadja az AuditLog bejegyzéseket, amelyek ehhez a providerhez tartoznak (target_type='service_provider', target_id=str(provider_id)). """ result = await db.execute( select(AuditLog) .where( AuditLog.target_type == "service_provider", AuditLog.target_id == str(provider_id), ) .order_by(AuditLog.timestamp.desc()) .limit(100) ) logs = result.scalars().all() entries = [] for log in logs: new_data = log.new_data or {} entries.append(ProviderHistoryEntry( id=log.id, action=log.action, user_id=log.user_id, old_data=log.old_data, new_data=new_data, timestamp=log.timestamp, )) return entries @router.patch("/{provider_id}", response_model=ProviderDetail) async def update_provider( provider_id: int, update_data: ProviderUpdateInput, db: AsyncSession = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("providers:moderate")), ): """ Szolgáltató adatainak és státuszának szerkesztése. Lehetőség van: - Adatmezők módosítására (name, city, address, contact, category) - Státusz módosítására (status mező: pending/approved/rejected) - Validációs pontszám módosítására (validation_score) - Kategória ID-k szinkronizálására (category_ids -> ServiceExpertise) - Járműosztályok és specializációk mentésére (supported_vehicle_classes, specializations) Minden változás auditálásra kerül az AuditLog táblában. """ provider = await _get_provider_or_404(db, provider_id) # Változások nyomon követése auditáláshoz old_status = provider.status.value if hasattr(provider.status, 'value') else str(provider.status) old_data = { "name": provider.name, "city": provider.city, "address_zip": provider.address_zip, "address_street_name": provider.address_street_name, "address_street_type": provider.address_street_type, "address_house_number": provider.address_house_number, "plus_code": provider.plus_code, "contact_phone": provider.contact_phone, "contact_email": provider.contact_email, "website": provider.website, "category": provider.category, "status": old_status, "validation_score": provider.validation_score, "supported_vehicle_classes": provider.supported_vehicle_classes, "specializations": provider.specializations, } # Mezők frissítése (csak a nem None értékeket) update_fields = update_data.model_dump(exclude_none=True) # ===================================================================== # STEP 0: Extract address_detail (AddressIn) and map to flat model fields # ===================================================================== address_detail: Optional[dict] = update_fields.pop("address_detail", None) if address_detail: # Map AddressIn fields to ServiceProvider flat columns addr_mapping = { "zip": "address_zip", "city": None, # city is a direct column, handled separately "street_name": "address_street_name", "street_type": "address_street_type", "house_number": "address_house_number", "stairwell": "address_stairwell", "floor": "address_floor", "door": "address_door", "parcel_id": "address_hrsz", "full_address_text": None, # maps to address field "latitude": None, # maps to latitude "longitude": None, # maps to longitude } for addr_field, model_field in addr_mapping.items(): if addr_field in address_detail and address_detail[addr_field] is not None: if model_field: update_fields[model_field] = address_detail[addr_field] elif addr_field == "full_address_text": update_fields["address"] = address_detail[addr_field] elif addr_field == "latitude": update_fields["latitude"] = address_detail[addr_field] elif addr_field == "longitude": update_fields["longitude"] = address_detail[addr_field] elif addr_field == "city": update_fields["city"] = address_detail[addr_field] # ===================================================================== # STEP 1: Extract multi-dimensional arrays and JSONB from payload # ===================================================================== category_ids: Optional[List[int]] = update_fields.pop("category_ids", None) supported_vehicle_classes: Optional[List[str]] = update_fields.pop("supported_vehicle_classes", None) specializations: Optional[dict] = update_fields.pop("specializations", None) opening_hours: Optional[dict] = update_fields.pop("opening_hours", None) is_always_open: Optional[bool] = update_fields.pop("is_always_open", None) # ===================================================================== # STEP 1b: Enforce 0-24 opening hours when is_always_open is True # ===================================================================== if is_always_open is True: # Force all 7 days to 00:00-23:59 and store always_open metadata opening_hours = { "always_open": True, "days": { "monday": {"open": "00:00", "close": "23:59"}, "tuesday": {"open": "00:00", "close": "23:59"}, "wednesday": {"open": "00:00", "close": "23:59"}, "thursday": {"open": "00:00", "close": "23:59"}, "friday": {"open": "00:00", "close": "23:59"}, "saturday": {"open": "00:00", "close": "23:59"}, "sunday": {"open": "00:00", "close": "23:59"}, }, } elif is_always_open is False and opening_hours is not None: # Strip always_open metadata if explicitly turning off 0-24 mode if isinstance(opening_hours, dict): opening_hours.pop("always_open", None) # Státusz módosítás kezelése new_status = update_fields.get("status") if new_status and new_status != old_status: # Ha státusz változik, naplózzuk külön akcióként provider.status = ModerationStatus(new_status) if new_status == "approved" and old_status == "rejected": # Restore esetén validation_score visszaállítás if "validation_score" not in update_fields: provider.validation_score = 50 elif new_status == "rejected": # Reject/flag esetén validation_score csökkentés if "validation_score" not in update_fields: provider.validation_score = max(0, (provider.validation_score or 0) - 20) # Töröljük a status-t a sima mezőkből, mert külön kezeltük del update_fields["status"] # ===================================================================== # STEP 2: Save supported_vehicle_classes and specializations to ServiceProvider # ===================================================================== if supported_vehicle_classes is not None: provider.supported_vehicle_classes = supported_vehicle_classes if specializations is not None: provider.specializations = specializations # Többi mező frissítése for field, value in update_fields.items(): if field != "status": setattr(provider, field, value) # ===================================================================== # STEP 3: Sync expertise tags and arrays to ServiceProfile # ===================================================================== # Find the ServiceProfile linked to this ServiceProvider profile_stmt = select(ServiceProfile).where( ServiceProfile.service_provider_id == provider_id ) profile_result = await db.execute(profile_stmt) profile = profile_result.scalar_one_or_none() if profile: # Save arrays to ServiceProfile as well if supported_vehicle_classes is not None: profile.supported_vehicle_classes = supported_vehicle_classes if specializations is not None: profile.specializations = specializations if opening_hours is not None: profile.opening_hours = opening_hours # Sync expertise tags if category_ids provided if category_ids is not None: await _sync_service_expertises( db=db, service_profile_id=profile.id, category_ids=category_ids, ) logger.info( f"ServiceExpertise synced for provider #{provider_id}: " f"service_profile_id={profile.id}, category_ids={category_ids}" ) # Új állapot rögzítése new_status_val = provider.status.value if hasattr(provider.status, 'value') else str(provider.status) # AuditLog - változások részletes naplózása new_data_snapshot = { "name": provider.name, "city": provider.city, "address_zip": provider.address_zip, "address_street_name": provider.address_street_name, "address_street_type": provider.address_street_type, "address_house_number": provider.address_house_number, "plus_code": provider.plus_code, "contact_phone": provider.contact_phone, "contact_email": provider.contact_email, "website": provider.website, "category": provider.category, "status": new_status_val, "validation_score": provider.validation_score, "supported_vehicle_classes": provider.supported_vehicle_classes, "specializations": provider.specializations, } log_entry = AuditLog( user_id=current_user.id, action="PROVIDER_UPDATED", target_type="service_provider", target_id=str(provider_id), old_data=old_data, new_data={ "reason": "Admin edit", "old_status": old_status, "new_status": new_status_val, "changes": new_data_snapshot, "timestamp": datetime.now(timezone.utc).isoformat(), }, ) db.add(log_entry) try: await db.commit() except IntegrityError as e: await db.rollback() logger.error(f"IntegrityError in update_provider #{provider_id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Database integrity error. Check constraints (e.g., duplicate data, invalid status value).", ) await db.refresh(provider) # Get opening_hours from the ServiceProfile for the response opening_hours_response = {} if profile: opening_hours_response = profile.opening_hours or {} return ProviderDetail( id=provider.id, name=provider.name, address=provider.address or "", city=provider.city, address_detail=None, plus_code=provider.plus_code, contact_phone=provider.contact_phone, contact_email=provider.contact_email, website=provider.website, category=provider.category, status=provider.status.value if hasattr(provider.status, 'value') else str(provider.status), source=provider.source.value if hasattr(provider.source, 'value') else str(provider.source), source_table="service_providers", validation_score=provider.validation_score or 0, evidence_image_path=provider.evidence_image_path, added_by_user_id=provider.added_by_user_id, category_ids=category_ids, supported_vehicle_classes=provider.supported_vehicle_classes or [], specializations=provider.specializations or {}, opening_hours=opening_hours_response, created_at=provider.created_at, )