Files
service-finder/backend/app/api/v1/endpoints/admin_providers.py
2026-07-08 08:03:57 +00:00

1788 lines
73 KiB
Python

"""
🏛️ 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)
P0 CRITICAL FIX (2026-07-07): Promotion Engine wiring.
- PATCH /admin/providers/{id} on a ServiceStaging record with status="approved"
now triggers the Promotion Engine: creates a ServiceProvider + ServiceProfile,
sets validation_score=100, then deletes the staging record.
- Auto-Validation: Admin approval sets validation_score=100 on the new ServiceProvider.
"""
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 sqlalchemy.orm.attributes import flag_modified
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, ServiceExpertise
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
from app.services.address_manager import AddressManager
logger = logging.getLogger("admin-providers")
router = APIRouter()
# =============================================================================
# Pydantic Schemas
# =============================================================================
class ProviderListItem(BaseModel):
"""Egy szolgáltató rövid adatai a listázáshoz.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressOut] — first-class citizen.
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
"""
id: int
name: str
address: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.full_address_text instead")
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
address_detail: Optional[AddressOut] = 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.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressOut] — first-class citizen.
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
"""
id: int
name: str
address: str = Field(..., description="[DEPRECATED] Use address_detail.full_address_text instead")
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
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.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressIn] — first-class citizen.
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
"""
name: Optional[str] = Field(None, min_length=1, max_length=255, description="Szolgáltató neve")
address: Optional[str] = Field(None, max_length=500, description="[DEPRECATED] Use address_detail.full_address_text instead")
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
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 ---
# P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
# Outerjoin to system.addresses and system.geo_postal_codes to pull
# address/city from the normalized Address table instead of flat columns.
# Coalesce to flat columns for backward compatibility during transition.
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"),
func.coalesce(
Address.full_address_text, ServiceProvider.address
).label("address"),
func.coalesce(
GeoPostalCode.city, 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"),
# P0 PHASE 2 ADDRESS UNIFICATION: Include address_id for address_detail resolution
ServiceProvider.address_id.label("address_id"),
).outerjoin(
Address,
Address.id == ServiceProvider.address_id,
).outerjoin(
GeoPostalCode,
GeoPostalCode.id == Address.postal_code_id,
)
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"),
# P0 PHASE 2: Placeholder for UNION ALL column count parity
literal(None).label("address_id"),
)
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"),
# P0 PHASE 2: Placeholder for UNION ALL column count parity
literal(None).label("address_id"),
).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:
# P0 PHASE 2 ADDRESS UNIFICATION: Resolve address_detail from address_id
row_address_detail = None
row_address_id = getattr(row, "address_id", None)
if row_address_id:
try:
addr_data = await AddressManager.get_normalized(db, row_address_id)
if addr_data:
row_address_detail = AddressOut(**addr_data)
except Exception as e:
logger.warning(
f"Failed to resolve address_detail for list row #{row.id}: {e}"
)
items.append(ProviderListItem(
id=row.id,
name=row.name,
address=row.address,
city=row.city,
address_detail=row_address_detail,
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:
# P0 BUGFIX (2026-07-07): Resolve category_ids from ServiceProfile → ServiceExpertise.
# Search by BOTH service_provider_id AND organization_id.
# ServiceProvider ID 6 (and other legacy providers) may have their ServiceProfile
# linked via organization_id instead of service_provider_id. Using OR ensures
# we find the profile regardless of which FK is populated.
resolved_category_ids: Optional[List[int]] = None
profile_stmt = select(ServiceProfile).where(
(ServiceProfile.service_provider_id == provider_id) |
(ServiceProfile.organization_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()]
else:
# P0 FINAL FIX (2026-07-07): If no ServiceProfile exists (e.g. legacy provider #6),
# return empty list instead of null. The PATCH endpoint auto-creates a ServiceProfile
# when category_ids are saved, but GET must not crash or return null for providers
# that haven't been edited yet.
resolved_category_ids = []
# Get opening_hours from the ServiceProfile (JSONB column)
opening_hours = profile.opening_hours if profile else {}
# P0 BUGFIX: Resolve address_detail from address_id if present
live_address_detail = None
live_address_id = getattr(sp, "address_id", None)
if live_address_id:
try:
addr_data = await AddressManager.get_normalized(db, live_address_id)
if addr_data:
live_address_detail = AddressOut(**addr_data)
except Exception as e:
logger.warning(
f"Failed to resolve address_detail for provider #{sp.id}: {e}"
)
return ProviderDetail(
id=sp.id,
name=sp.name,
address=sp.address or "",
city=sp.city,
address_detail=live_address_detail,
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: ServiceProvider model does NOT have raw_data, trust_score,
# rejection_reason, or audit_trail columns — those exist only on ServiceStaging.
# Explicitly pass None to avoid Pydantic/AttributeError 500 crash.
raw_data=None,
trust_score=None,
rejection_reason=None,
audit_trail=None,
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:
# P0 CRITICAL BUGFIX: Resolve address_detail from address_id if present
staging_address_detail = None
if ss.address_id:
try:
addr_data = await AddressManager.get_normalized(db, ss.address_id)
if addr_data:
staging_address_detail = AddressOut(**addr_data)
except Exception as e:
logger.warning(
f"Failed to resolve address_detail for staging #{ss.id}: {e}"
)
return ProviderDetail(
id=ss.id,
name=ss.name,
address=ss.full_address or "",
city=ss.city,
address_detail=staging_address_detail,
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.
"""
# P0 BUGFIX: First try ServiceProvider, then fall back to ServiceStaging
# (same pattern as get_provider_detail endpoint)
provider = await db.execute(
select(ServiceProvider).where(ServiceProvider.id == provider_id)
)
sp = provider.scalar_one_or_none()
staging_record = None
if sp:
# Found in ServiceProvider — use it
provider_obj = sp
is_staging = False
else:
# Try ServiceStaging
staging = await db.execute(
select(ServiceStaging).where(ServiceStaging.id == provider_id)
)
ss = staging.scalar_one_or_none()
if not ss:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Provider with ID {provider_id} not found in any table.",
)
provider_obj = ss
is_staging = True
# Változások nyomon követése auditáláshoz
old_status = provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status)
old_data = {
"name": provider_obj.name,
"city": provider_obj.city,
"address_zip": getattr(provider_obj, 'address_zip', None),
"address_street_name": getattr(provider_obj, 'address_street_name', None),
"address_street_type": getattr(provider_obj, 'address_street_type', None),
"address_house_number": getattr(provider_obj, 'address_house_number', None),
"plus_code": getattr(provider_obj, 'plus_code', None),
"contact_phone": provider_obj.contact_phone,
"contact_email": provider_obj.contact_email,
"website": provider_obj.website,
"category": getattr(provider_obj, 'category', None),
"status": old_status,
"validation_score": getattr(provider_obj, 'validation_score', 0),
"supported_vehicle_classes": getattr(provider_obj, 'supported_vehicle_classes', None),
"specializations": getattr(provider_obj, 'specializations', None),
}
# Mezők frissítése (csak a nem None értékeket)
update_fields = update_data.model_dump(exclude_none=True)
# =====================================================================
# P0 CRITICAL BUGFIX: Staging records (ServiceStaging) have a different
# column set than ServiceProvider. ServiceStaging does NOT have:
# address_zip, address_street_name, address_street_type,
# address_house_number, address_stairwell, address_floor, address_door,
# address_hrsz, latitude, longitude, category, validation_score,
# supported_vehicle_classes, specializations
# For staging records, we must:
# 1. Apply ONLY flat columns that exist on ServiceStaging
# 2. Merge complex data (address_detail, specializations, category_ids)
# into raw_data so they are preserved for the Promotion Engine
# =====================================================================
# =====================================================================
# STEP 0: Extract address_detail (AddressIn) and map to flat model fields
# =====================================================================
address_detail: Optional[dict] = update_fields.pop("address_detail", None)
if is_staging and address_detail:
# P0 CRITICAL BUGFIX: Use AddressManager to create/update the Address
# record in system.addresses, then link it via address_id on ServiceStaging.
# This ensures the address_detail is NOT lost — it's stored in the
# normalized Address table and can be returned in the API response.
addr_in = AddressIn(**address_detail)
new_address_id = await AddressManager.create_or_update(
db=db,
address_id=getattr(provider_obj, "address_id", None),
data=addr_in,
)
if new_address_id:
provider_obj.address_id = new_address_id
# Also update full_address if full_address_text is provided
if address_detail.get("full_address_text"):
update_fields["full_address"] = address_detail["full_address_text"]
if address_detail.get("city"):
update_fields["city"] = address_detail["city"]
elif address_detail:
# P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
# Use AddressManager to create/update the normalized system.addresses
# record and link it via address_id on ServiceProvider.
# Flat column writes (address_zip, address_street_name, etc.) are
# STRICTLY REMOVED — AddressManager is the single source of truth.
addr_in = AddressIn(**address_detail)
new_address_id = await AddressManager.create_or_update(
db=db,
address_id=getattr(provider_obj, "address_id", None),
data=addr_in,
)
if new_address_id:
provider_obj.address_id = new_address_id
# =====================================================================
# 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
if not is_staging:
provider_obj.status = ModerationStatus(new_status)
else:
provider_obj.status = 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:
if hasattr(provider_obj, 'validation_score'):
provider_obj.validation_score = 50
elif new_status == "rejected":
# Reject/flag esetén validation_score csökkentés
if "validation_score" not in update_fields:
if hasattr(provider_obj, 'validation_score'):
provider_obj.validation_score = max(0, (provider_obj.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"]
# =====================================================================
# P0 CRITICAL FIX (2026-07-07 v2): PROMOTION ENGINE
# When a ServiceStaging record is approved by admin, promote it to a
# live ServiceProvider + ServiceProfile, then delete the staging record.
#
# P0 CRITICAL BUGFIX v2 (2026-07-07):
# - Trigger condition now checks provider_obj.status directly (the actual
# DB state after update) instead of relying on new_status from update_fields.
# This ensures promotion fires even if status was already "approved" in DB
# or if status wasn't explicitly passed in the PATCH payload.
# - After promotion, ALL references to provider_id (AuditLog, profile lookup)
# now use new_provider.id, NOT the old staging provider_id.
# - Added logger.error("P0 PROMOTION TRIGGERED") for Docker traceability.
# =====================================================================
# Determine the effective status: use the newly set status on provider_obj,
# or fall back to the current DB status. This is more robust than relying
# on new_status from update_fields, which may be None if status wasn't
# explicitly included in the PATCH payload.
effective_status = (
provider_obj.status.value
if hasattr(provider_obj.status, 'value')
else str(provider_obj.status)
)
if is_staging and effective_status == "approved":
logger.error(
f"P0 PROMOTION TRIGGERED: Staging #{provider_id} "
f"('{provider_obj.name}') effective_status='{effective_status}', "
f"new_status_from_payload='{new_status}', "
f"approved by admin #{current_user.id}. Promoting to live..."
)
# 1. Create ServiceProvider from staging data
from app.models.identity.social import SourceType
new_provider = ServiceProvider(
name=provider_obj.name,
address=provider_obj.full_address or "",
city=provider_obj.city,
address_id=getattr(provider_obj, "address_id", None),
plus_code=getattr(provider_obj, "plus_code", None),
contact_phone=provider_obj.contact_phone,
contact_email=provider_obj.contact_email,
website=provider_obj.website,
category=update_fields.get("category") or getattr(provider_obj, "category", None),
status=ModerationStatus.approved,
source=SourceType.manual,
validation_score=100, # Auto-Validation: admin approval = 100
added_by_user_id=current_user.id,
)
db.add(new_provider)
await db.flush()
# 2. Create ServiceProfile linked to the new ServiceProvider
profile = ServiceProfile(
service_provider_id=new_provider.id,
fingerprint=getattr(provider_obj, "fingerprint", None),
status="active",
trust_score=100,
is_verified=True,
contact_phone=provider_obj.contact_phone,
contact_email=provider_obj.contact_email,
website=provider_obj.website,
opening_hours=opening_hours or {},
)
db.add(profile)
await db.flush()
# 3. Sync category_ids if provided (from raw_data or direct input)
promoted_category_ids = category_ids
if not promoted_category_ids and provider_obj.raw_data:
promoted_category_ids = provider_obj.raw_data.get("category_ids")
if promoted_category_ids:
await _sync_service_expertises(
db=db,
service_profile_id=profile.id,
category_ids=promoted_category_ids,
)
logger.info(
f"P0 PROMOTION: ServiceExpertise synced for new provider "
f"#{new_provider.id}, category_ids={promoted_category_ids}"
)
# 4. Log the promotion — use new_provider.id, NOT the old staging provider_id
await _log_moderation_action(
db=db,
user_id=current_user.id,
action="PROVIDER_PROMOTED_FROM_STAGING",
provider_id=new_provider.id,
reason=f"Promoted from ServiceStaging #{provider_id} by admin",
old_status=old_status,
new_status="approved",
source_table="service_staging",
)
# 5. Delete the staging record (promotion complete)
await db.delete(provider_obj)
await db.flush()
logger.info(
f"P0 PROMOTION COMPLETE: Staging #{provider_id}"
f"ServiceProvider #{new_provider.id}, "
f"ServiceProfile #{profile.id}, validation_score=100"
)
# Switch to the new provider for the response
provider_obj = new_provider
is_staging = False
# Reset update_fields since we've already created everything
update_fields.clear()
# =====================================================================
# STEP 2: Save supported_vehicle_classes and specializations
# =====================================================================
if is_staging:
# P0 BUGFIX: ServiceStaging has no supported_vehicle_classes/specializations columns.
# Merge them into raw_data for the Promotion Engine.
# CRITICAL: Use flag_modified() so SQLAlchemy detects the JSONB mutation.
raw_data_mutated = False
if supported_vehicle_classes is not None or specializations is not None or category_ids is not None or opening_hours is not None:
if not provider_obj.raw_data:
provider_obj.raw_data = {}
if supported_vehicle_classes is not None:
provider_obj.raw_data["supported_vehicle_classes"] = supported_vehicle_classes
raw_data_mutated = True
if specializations is not None:
provider_obj.raw_data["specializations"] = specializations
raw_data_mutated = True
if category_ids is not None:
provider_obj.raw_data["category_ids"] = category_ids
raw_data_mutated = True
if opening_hours is not None:
provider_obj.raw_data["opening_hours"] = opening_hours
raw_data_mutated = True
if raw_data_mutated:
flag_modified(provider_obj, "raw_data")
else:
if hasattr(provider_obj, 'supported_vehicle_classes') and supported_vehicle_classes is not None:
provider_obj.supported_vehicle_classes = supported_vehicle_classes
if hasattr(provider_obj, 'specializations') and specializations is not None:
provider_obj.specializations = specializations
# =====================================================================
# STEP 2b: For staging records, strip fields that don't exist on ServiceStaging
# =====================================================================
if is_staging:
# P0 BUGFIX: Only keep fields that actually exist on ServiceStaging model
staging_allowed_fields = {
"name", "city", "full_address", "contact_phone", "website",
"contact_email", "description", "status", "plus_code",
"source", "trust_score", "rejection_reason",
}
# Map 'address' from ProviderUpdateInput to 'full_address' on ServiceStaging
if "address" in update_fields:
update_fields["full_address"] = update_fields.pop("address")
# Remove fields that don't exist on ServiceStaging
raw_data_mutated = False
for field in list(update_fields.keys()):
if field not in staging_allowed_fields:
# Merge unknown fields into raw_data so they aren't lost
if field not in ("status",):
if not provider_obj.raw_data:
provider_obj.raw_data = {}
provider_obj.raw_data[field] = update_fields[field]
raw_data_mutated = True
del update_fields[field]
if raw_data_mutated:
flag_modified(provider_obj, "raw_data")
# Többi mező frissítése
for field, value in update_fields.items():
if field != "status" and hasattr(provider_obj, field):
setattr(provider_obj, field, value)
# =====================================================================
# STEP 3: Sync expertise tags and arrays to ServiceProfile
# (Only for ServiceProvider records that have a linked ServiceProfile)
# =====================================================================
profile = None
if not is_staging:
# P0 CRITICAL BUGFIX v2: Use provider_obj.id instead of provider_id.
# After promotion, provider_obj points to the NEW ServiceProvider,
# but provider_id still holds the old staging ID (e.g., 1754).
# Using provider_obj.id ensures we find the correct ServiceProfile.
# P0 BUGFIX (2026-07-07): Search by BOTH service_provider_id AND
# organization_id. ServiceProvider ID 6 (and other legacy providers)
# may have their ServiceProfile linked via organization_id instead of
# service_provider_id. Using OR ensures we find the profile regardless
# of which FK is populated.
profile_stmt = select(ServiceProfile).where(
(ServiceProfile.service_provider_id == provider_obj.id) |
(ServiceProfile.organization_id == provider_obj.id)
)
profile_result = await db.execute(profile_stmt)
profile = profile_result.scalar_one_or_none()
# P0 BUGFIX (2026-07-07): If no ServiceProfile exists for this Live provider,
# CREATE ONE automatically so that advanced fields (opening_hours,
# specializations, supported_vehicle_classes, category_ids) are not silently lost.
# P0 CRITICAL BUGFIX (2026-07-07): ServiceProfile creation is now UNCONDITIONAL.
# Previously, the profile was only created if has_advanced_fields was True.
# This caused a 500 error when a PATCH with category_ids was sent for a newly
# created provider that had no ServiceProfile yet, because _sync_service_expertises
# requires a valid profile.id. Now we ALWAYS create the profile if it doesn't exist.
if not profile:
profile = ServiceProfile(
service_provider_id=provider_obj.id,
fingerprint=f"auto-created-{provider_obj.id}",
status="active",
trust_score=30,
is_verified=False,
contact_phone=provider_obj.contact_phone,
contact_email=provider_obj.contact_email,
website=provider_obj.website,
opening_hours=opening_hours or {},
supported_vehicle_classes=supported_vehicle_classes or [],
specializations=specializations or {},
)
db.add(profile)
await db.flush()
logger.info(
f"P0 BUGFIX: Auto-created ServiceProfile #{profile.id} "
f"for Live provider #{provider_obj.id} ('{provider_obj.name}')"
)
# P0 CRITICAL BUGFIX (2026-07-07): Debug logging to trace profile.id
# before _sync_service_expertises call. This ensures we can identify
# the exact state in Docker logs if the 500 error recurs.
print(f"DEBUG: Syncing categories for profile_id: {profile.id}")
logger.info(
f"P0 DEBUG: ServiceProfile resolved — profile_id={profile.id}, "
f"provider_id={provider_obj.id}, category_ids={category_ids}"
)
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_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status)
# AuditLog - változások részletes naplózása
new_data_snapshot = {
"name": provider_obj.name,
"city": provider_obj.city,
"address_zip": getattr(provider_obj, 'address_zip', None),
"address_street_name": getattr(provider_obj, 'address_street_name', None),
"address_street_type": getattr(provider_obj, 'address_street_type', None),
"address_house_number": getattr(provider_obj, 'address_house_number', None),
"plus_code": getattr(provider_obj, 'plus_code', None),
"contact_phone": provider_obj.contact_phone,
"contact_email": provider_obj.contact_email,
"website": provider_obj.website,
"category": getattr(provider_obj, 'category', None),
"status": new_status_val,
"validation_score": getattr(provider_obj, 'validation_score', 0),
"supported_vehicle_classes": getattr(provider_obj, 'supported_vehicle_classes', None),
"specializations": getattr(provider_obj, 'specializations', None),
}
# P0 CRITICAL BUGFIX v2: Use provider_obj.id instead of provider_id.
# After promotion, provider_obj points to the NEW ServiceProvider,
# but provider_id still holds the old staging ID (e.g., 1754).
# The AuditLog must reference the new provider's ID.
log_entry = AuditLog(
user_id=current_user.id,
action="PROVIDER_UPDATED",
target_type="service_provider",
target_id=str(provider_obj.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_obj)
# Get opening_hours from the ServiceProfile for the response
opening_hours_response = {}
resolved_category_ids: Optional[List[int]] = None
if profile:
opening_hours_response = profile.opening_hours or {}
# P0 BUGFIX: Resolve category_ids from the actual DB state (ServiceExpertise)
# instead of relying on the payload value, which may be stale or None.
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()]
if not is_staging:
# P0 BUGFIX: Resolve address_detail from address_id if present
live_address_detail = None
live_address_id = getattr(provider_obj, "address_id", None)
if live_address_id:
try:
addr_data = await AddressManager.get_normalized(db, live_address_id)
if addr_data:
live_address_detail = AddressOut(**addr_data)
except Exception as e:
logger.warning(
f"Failed to resolve address_detail for provider #{provider_obj.id}: {e}"
)
return ProviderDetail(
id=provider_obj.id,
name=provider_obj.name,
address=provider_obj.address or "",
city=provider_obj.city,
address_detail=live_address_detail,
plus_code=provider_obj.plus_code,
contact_phone=provider_obj.contact_phone,
contact_email=provider_obj.contact_email,
website=provider_obj.website,
category=provider_obj.category,
status=provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status),
source=provider_obj.source.value if hasattr(provider_obj.source, 'value') else str(provider_obj.source),
source_table="service_providers",
validation_score=getattr(provider_obj, 'validation_score', 0) or 0,
evidence_image_path=getattr(provider_obj, 'evidence_image_path', None),
added_by_user_id=getattr(provider_obj, 'added_by_user_id', None),
category_ids=resolved_category_ids or category_ids,
supported_vehicle_classes=getattr(provider_obj, 'supported_vehicle_classes', None) or [],
specializations=getattr(provider_obj, 'specializations', None) or {},
opening_hours=opening_hours_response,
raw_data=None,
trust_score=None,
rejection_reason=None,
audit_trail=None,
created_at=provider_obj.created_at,
)
else:
# Staging record response
# P0 CRITICAL BUGFIX: Resolve address_detail from address_id if present
staging_address_detail = None
staging_address_id = getattr(provider_obj, "address_id", None)
if staging_address_id:
try:
addr_data = await AddressManager.get_normalized(db, staging_address_id)
if addr_data:
staging_address_detail = AddressOut(**addr_data)
except Exception as e:
logger.warning(
f"Failed to resolve address_detail for staging #{provider_obj.id}: {e}"
)
return ProviderDetail(
id=provider_obj.id,
name=provider_obj.name,
address=provider_obj.full_address or "",
city=provider_obj.city,
address_detail=staging_address_detail,
plus_code=getattr(provider_obj, 'plus_code', None),
contact_phone=provider_obj.contact_phone,
contact_email=provider_obj.contact_email,
website=provider_obj.website,
description=getattr(provider_obj, 'description', None),
status=provider_obj.status,
source=provider_obj.source or "bot",
source_table="service_staging",
validation_score=getattr(provider_obj, 'trust_score', 0) or 0,
trust_score=provider_obj.trust_score,
rejection_reason=provider_obj.rejection_reason,
raw_data=provider_obj.raw_data or {},
audit_trail=provider_obj.audit_trail or {},
created_at=provider_obj.created_at,
)