1362 lines
51 KiB
Python
1362 lines
51 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)
|
|
"""
|
|
|
|
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 ServiceStaging, ServiceProfile
|
|
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
|
|
status: str
|
|
source: str
|
|
source_table: str = "service_providers"
|
|
validation_score: int = 0
|
|
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=AddressOut(
|
|
zip=sp.address_zip,
|
|
city=sp.city,
|
|
street_name=sp.address_street_name,
|
|
street_type=sp.address_street_type,
|
|
house_number=sp.address_house_number,
|
|
),
|
|
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 {},
|
|
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,
|
|
status=ss.status,
|
|
source="bot",
|
|
source_table="service_staging",
|
|
validation_score=0,
|
|
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=AddressOut(
|
|
zip=provider.address_zip,
|
|
city=provider.city,
|
|
street_name=provider.address_street_name,
|
|
street_type=provider.address_street_type,
|
|
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=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,
|
|
) |