Files
service-finder/backend/app/api/v1/endpoints/admin_providers.py
2026-07-01 19:49:58 +00:00

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