frontend admin refakctorálás
This commit is contained in:
@@ -1188,6 +1188,152 @@ async def review_contribution(
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VALIDATION RULES (Provider Validation Engine)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ValidationRuleUpdate(BaseModel):
|
||||
"""Update payload for a single validation rule."""
|
||||
rule_value: int = Field(..., ge=0, le=100, description="New integer value for this rule (0-100)")
|
||||
description: Optional[str] = Field(None, max_length=500, description="Updated description")
|
||||
|
||||
|
||||
class ValidationRuleBulkUpdate(BaseModel):
|
||||
"""Bulk update payload — dict of rule_key → new_value."""
|
||||
rules: Dict[str, int] = Field(
|
||||
..., description="Mapping of rule_key → new integer value (0-100)"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/validation-rules", response_model=List[Dict[str, Any]])
|
||||
async def list_validation_rules(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_: User = Depends(deps.RequirePermission("gamification:manage")),
|
||||
):
|
||||
"""
|
||||
Fetch all ValidationRule records from the database.
|
||||
|
||||
Returns a list of {id, rule_key, rule_value, description, updated_at}.
|
||||
These rules drive the ValidationEngine for provider scoring and
|
||||
auto-approval thresholds.
|
||||
"""
|
||||
from app.models.gamification.validation_rule import ValidationRule
|
||||
|
||||
stmt = select(ValidationRule).order_by(ValidationRule.rule_key)
|
||||
result = await db.execute(stmt)
|
||||
rules = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"rule_key": r.rule_key,
|
||||
"rule_value": r.rule_value,
|
||||
"description": r.description,
|
||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||
}
|
||||
for r in rules
|
||||
]
|
||||
|
||||
|
||||
@router.put("/validation-rules", response_model=Dict[str, Any])
|
||||
async def bulk_update_validation_rules(
|
||||
data: ValidationRuleBulkUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_: User = Depends(deps.RequirePermission("gamification:manage")),
|
||||
):
|
||||
"""
|
||||
Bulk-update ValidationRule values.
|
||||
|
||||
Accepts a dict of rule_key → new_value. Iterates through each entry
|
||||
and updates rule_value in the database. Unknown keys are silently skipped.
|
||||
|
||||
Returns a summary of updated rules.
|
||||
"""
|
||||
from app.models.gamification.validation_rule import ValidationRule
|
||||
|
||||
updated: List[Dict[str, Any]] = []
|
||||
errors: List[Dict[str, Any]] = []
|
||||
|
||||
for rule_key, new_value in data.rules.items():
|
||||
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_key)
|
||||
rule = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not rule:
|
||||
errors.append({"rule_key": rule_key, "error": "Rule not found"})
|
||||
logger.warning(f"Validation rule '{rule_key}' not found, skipping.")
|
||||
continue
|
||||
|
||||
if not (0 <= new_value <= 100):
|
||||
errors.append({"rule_key": rule_key, "error": "Value must be between 0 and 100"})
|
||||
continue
|
||||
|
||||
old_val = rule.rule_value
|
||||
rule.rule_value = new_value
|
||||
updated.append({
|
||||
"rule_key": rule_key,
|
||||
"old_value": old_val,
|
||||
"new_value": new_value,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Updated {len(updated)} rule(s) successfully.",
|
||||
"updated": updated,
|
||||
"errors": errors if errors else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/validation-rules/{rule_key}", response_model=Dict[str, Any])
|
||||
async def update_validation_rule(
|
||||
rule_key: str,
|
||||
data: ValidationRuleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_: User = Depends(deps.RequirePermission("gamification:manage")),
|
||||
):
|
||||
"""
|
||||
Update a single ValidationRule by its rule_key.
|
||||
|
||||
Allows updating both rule_value and description.
|
||||
"""
|
||||
from app.models.gamification.validation_rule import ValidationRule
|
||||
|
||||
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_key)
|
||||
rule = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not rule:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Validation rule '{rule_key}' not found.",
|
||||
)
|
||||
|
||||
old_value = rule.rule_value
|
||||
rule.rule_value = data.rule_value
|
||||
if data.description is not None:
|
||||
rule.description = data.description
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
|
||||
logger.info(
|
||||
f"Validation rule '{rule_key}' updated: {old_value} → {data.rule_value}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": rule.id,
|
||||
"rule_key": rule.rule_key,
|
||||
"rule_value": rule.rule_value,
|
||||
"description": rule.description,
|
||||
"old_value": old_value,
|
||||
"updated_at": rule.updated_at.isoformat() if rule.updated_at else None,
|
||||
"message": f"Rule '{rule_key}' updated successfully.",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LEADERBOARD (Admin)
|
||||
# =============================================================================
|
||||
|
||||
@@ -707,17 +707,22 @@ async def get_organization_details(
|
||||
|
||||
# 2c. P0 ADD-ON: Telephelyek (branches) lekérése
|
||||
from app.models.marketplace.organization import Branch # noqa: E402
|
||||
branches_stmt = select(Branch).where(
|
||||
Branch.organization_id == org_id,
|
||||
Branch.is_deleted == False,
|
||||
).order_by(Branch.name)
|
||||
branches_stmt = (
|
||||
select(Branch)
|
||||
.options(selectinload(Branch.address))
|
||||
.where(
|
||||
Branch.organization_id == org_id,
|
||||
Branch.is_deleted == False,
|
||||
)
|
||||
.order_by(Branch.name)
|
||||
)
|
||||
branches_result = await db.execute(branches_stmt)
|
||||
branch_rows = branches_result.scalars().all()
|
||||
branches_list = [
|
||||
BranchBrief(
|
||||
id=str(b.id),
|
||||
name=b.name,
|
||||
city=b.city,
|
||||
city=b.address.city if b.address else None,
|
||||
is_active=(b.status == "active") if b.status else True,
|
||||
)
|
||||
for b in branch_rows
|
||||
@@ -1319,6 +1324,10 @@ async def update_organization(
|
||||
# ── Primary address via AddressManager ──
|
||||
address_detail: Optional[AddressIn] = raw_data.pop("address_detail", None)
|
||||
if address_detail:
|
||||
# P0 BUGFIX: model_dump() converts nested Pydantic models to dicts;
|
||||
# re-wrap into AddressIn before passing to AddressManager
|
||||
if isinstance(address_detail, dict):
|
||||
address_detail = AddressIn(**address_detail)
|
||||
org.address_id = await AddressManager.create_or_update(
|
||||
db, org.address_id, address_detail
|
||||
)
|
||||
@@ -1326,6 +1335,8 @@ async def update_organization(
|
||||
# ── Billing address via AddressManager ──
|
||||
billing_address_detail: Optional[AddressIn] = raw_data.pop("billing_address_detail", None)
|
||||
if billing_address_detail:
|
||||
if isinstance(billing_address_detail, dict):
|
||||
billing_address_detail = AddressIn(**billing_address_detail)
|
||||
org.billing_address_id = await AddressManager.create_or_update(
|
||||
db, org.billing_address_id, billing_address_detail
|
||||
)
|
||||
@@ -1333,6 +1344,8 @@ async def update_organization(
|
||||
# ── Notification address via AddressManager ──
|
||||
notification_address_detail: Optional[AddressIn] = raw_data.pop("notification_address_detail", None)
|
||||
if notification_address_detail:
|
||||
if isinstance(notification_address_detail, dict):
|
||||
notification_address_detail = AddressIn(**notification_address_detail)
|
||||
org.notification_address_id = await AddressManager.create_or_update(
|
||||
db, org.notification_address_id, notification_address_detail
|
||||
)
|
||||
|
||||
@@ -14,6 +14,12 @@ Végpontok:
|
||||
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
|
||||
@@ -27,6 +33,7 @@ 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
|
||||
@@ -38,12 +45,13 @@ from app.models.identity.social import (
|
||||
SourceType,
|
||||
ProviderValidation,
|
||||
)
|
||||
from app.models.marketplace.service import ServiceProfile
|
||||
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()
|
||||
@@ -55,11 +63,17 @@ router = APIRouter()
|
||||
|
||||
|
||||
class ProviderListItem(BaseModel):
|
||||
"""Egy szolgáltató rövid adatai a listázáshoz."""
|
||||
"""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] = None
|
||||
city: Optional[str] = None
|
||||
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
|
||||
@@ -72,11 +86,16 @@ class ProviderListItem(BaseModel):
|
||||
|
||||
|
||||
class ProviderDetail(BaseModel):
|
||||
"""Szolgáltató részletes adatai."""
|
||||
"""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
|
||||
city: Optional[str] = None
|
||||
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
|
||||
@@ -130,10 +149,15 @@ class ModerationResponse(BaseModel):
|
||||
|
||||
|
||||
class ProviderUpdateInput(BaseModel):
|
||||
"""Provider adatok szerkesztésének bemeneti sémája."""
|
||||
"""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="Teljes cím")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
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")
|
||||
@@ -294,6 +318,10 @@ def _build_unified_providers_query(
|
||||
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)
|
||||
@@ -303,8 +331,12 @@ def _build_unified_providers_query(
|
||||
provider_stmt = select(
|
||||
ServiceProvider.id.label("id"),
|
||||
ServiceProvider.name.label("name"),
|
||||
ServiceProvider.address.label("address"),
|
||||
ServiceProvider.city.label("city"),
|
||||
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"),
|
||||
@@ -312,6 +344,14 @@ def _build_unified_providers_query(
|
||||
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)
|
||||
@@ -341,6 +381,8 @@ def _build_unified_providers_query(
|
||||
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)
|
||||
@@ -375,6 +417,8 @@ def _build_unified_providers_query(
|
||||
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,
|
||||
@@ -463,11 +507,25 @@ async def list_providers(
|
||||
|
||||
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,
|
||||
@@ -548,10 +606,15 @@ async def get_provider_detail(
|
||||
)
|
||||
sp = provider.scalar_one_or_none()
|
||||
if sp:
|
||||
# Lekérdezzük a kapcsolódó kategória ID-kat a ServiceProfile-on keresztül
|
||||
# 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.service_provider_id == provider_id) |
|
||||
(ServiceProfile.organization_id == provider_id)
|
||||
)
|
||||
profile_result = await db.execute(profile_stmt)
|
||||
profile = profile_result.scalar_one_or_none()
|
||||
@@ -561,16 +624,35 @@ async def get_provider_detail(
|
||||
)
|
||||
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=None,
|
||||
address_detail=live_address_detail,
|
||||
plus_code=sp.plus_code,
|
||||
contact_phone=sp.contact_phone,
|
||||
contact_email=sp.contact_email,
|
||||
@@ -586,9 +668,13 @@ async def get_provider_detail(
|
||||
supported_vehicle_classes=sp.supported_vehicle_classes or [],
|
||||
specializations=sp.specializations or {},
|
||||
opening_hours=opening_hours or {},
|
||||
# P0 BUGFIX: Include raw_data for ServiceProvider records so the frontend
|
||||
# "Nyers adatok / Crawler" tab shows actual data instead of empty/mock state
|
||||
raw_data=sp.raw_data or {},
|
||||
# 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,
|
||||
)
|
||||
|
||||
@@ -598,11 +684,24 @@ async def get_provider_detail(
|
||||
)
|
||||
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,
|
||||
@@ -1142,64 +1241,104 @@ async def update_provider(
|
||||
|
||||
Minden változás auditálásra kerül az AuditLog táblában.
|
||||
"""
|
||||
provider = await _get_provider_or_404(db, provider_id)
|
||||
# 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.status.value if hasattr(provider.status, 'value') else str(provider.status)
|
||||
old_status = provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.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,
|
||||
"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": provider.validation_score,
|
||||
"supported_vehicle_classes": provider.supported_vehicle_classes,
|
||||
"specializations": provider.specializations,
|
||||
"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 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]
|
||||
|
||||
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
|
||||
# =====================================================================
|
||||
@@ -1235,89 +1374,307 @@ async def update_provider(
|
||||
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 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:
|
||||
provider.validation_score = 50
|
||||
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:
|
||||
provider.validation_score = max(0, (provider.validation_score or 0) - 20)
|
||||
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"]
|
||||
|
||||
# =====================================================================
|
||||
# STEP 2: Save supported_vehicle_classes and specializations to ServiceProvider
|
||||
# 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.
|
||||
# =====================================================================
|
||||
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
|
||||
# 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)
|
||||
)
|
||||
profile_result = await db.execute(profile_stmt)
|
||||
profile = profile_result.scalar_one_or_none()
|
||||
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..."
|
||||
)
|
||||
|
||||
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
|
||||
# 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()
|
||||
|
||||
# Sync expertise tags if category_ids provided
|
||||
if category_ids is not None:
|
||||
# 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=category_ids,
|
||||
category_ids=promoted_category_ids,
|
||||
)
|
||||
logger.info(
|
||||
f"ServiceExpertise synced for provider #{provider_id}: "
|
||||
f"service_profile_id={profile.id}, category_ids={category_ids}"
|
||||
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.status.value if hasattr(provider.status, 'value') else str(provider.status)
|
||||
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.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,
|
||||
"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": provider.validation_score,
|
||||
"supported_vehicle_classes": provider.supported_vehicle_classes,
|
||||
"specializations": provider.specializations,
|
||||
"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_id),
|
||||
target_id=str(provider_obj.id),
|
||||
old_data=old_data,
|
||||
new_data={
|
||||
"reason": "Admin edit",
|
||||
@@ -1338,33 +1695,94 @@ async def update_provider(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Database integrity error. Check constraints (e.g., duplicate data, invalid status value).",
|
||||
)
|
||||
await db.refresh(provider)
|
||||
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()]
|
||||
|
||||
return ProviderDetail(
|
||||
id=provider.id,
|
||||
name=provider.name,
|
||||
address=provider.address or "",
|
||||
city=provider.city,
|
||||
address_detail=None,
|
||||
plus_code=provider.plus_code,
|
||||
contact_phone=provider.contact_phone,
|
||||
contact_email=provider.contact_email,
|
||||
website=provider.website,
|
||||
category=provider.category,
|
||||
status=provider.status.value if hasattr(provider.status, 'value') else str(provider.status),
|
||||
source=provider.source.value if hasattr(provider.source, 'value') else str(provider.source),
|
||||
source_table="service_providers",
|
||||
validation_score=provider.validation_score or 0,
|
||||
evidence_image_path=provider.evidence_image_path,
|
||||
added_by_user_id=provider.added_by_user_id,
|
||||
category_ids=category_ids,
|
||||
supported_vehicle_classes=provider.supported_vehicle_classes or [],
|
||||
specializations=provider.specializations or {},
|
||||
opening_hours=opening_hours_response,
|
||||
created_at=provider.created_at,
|
||||
)
|
||||
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,
|
||||
)
|
||||
@@ -97,6 +97,9 @@ async def list_body_types(
|
||||
"id": r.id,
|
||||
"vehicle_class": r.vehicle_class,
|
||||
"code": r.code,
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": r.name_i18n if r.name_i18n else {},
|
||||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
||||
"name_hu": r.name_hu,
|
||||
}
|
||||
for r in rows
|
||||
|
||||
@@ -15,9 +15,10 @@ import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy import select, or_, cast, String
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
@@ -76,6 +77,10 @@ async def get_category_tree(
|
||||
nodes.append(CategoryTreeNode(
|
||||
id=tag.id,
|
||||
key=tag.key,
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
name_i18n=tag.name_i18n if tag.name_i18n else {},
|
||||
description_i18n=tag.description_i18n,
|
||||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
||||
name_hu=tag.name_hu,
|
||||
name_en=tag.name_en,
|
||||
level=tag.level,
|
||||
@@ -106,16 +111,19 @@ async def autocomplete_categories(
|
||||
Szöveges kereső a Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkékhez.
|
||||
|
||||
Csak azokat listázza, ahol is_official=True.
|
||||
A keresés a name_hu, name_en és key mezőkben történik (ILIKE).
|
||||
A keresés a name_i18n JSONB (minden nyelven), key és name_hu/name_en mezőkben történik.
|
||||
"""
|
||||
try:
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3): JSONB → String cast keresés ---
|
||||
stmt = select(ExpertiseTag).where(
|
||||
ExpertiseTag.level >= 2,
|
||||
ExpertiseTag.is_official == True,
|
||||
or_(
|
||||
cast(ExpertiseTag.name_i18n, String).ilike(f"%{q}%"),
|
||||
ExpertiseTag.key.ilike(f"%{q}%"),
|
||||
# --- [DEPRECATED] Old column fallback (Phase 3) ---
|
||||
ExpertiseTag.name_hu.ilike(f"%{q}%"),
|
||||
ExpertiseTag.name_en.ilike(f"%{q}%"),
|
||||
ExpertiseTag.key.ilike(f"%{q}%"),
|
||||
),
|
||||
).order_by(ExpertiseTag.level, ExpertiseTag.name_hu).limit(20)
|
||||
|
||||
@@ -126,6 +134,9 @@ async def autocomplete_categories(
|
||||
CategoryAutocompleteItem(
|
||||
id=t.id,
|
||||
key=t.key,
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
name_i18n=t.name_i18n if t.name_i18n else {},
|
||||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
||||
name_hu=t.name_hu,
|
||||
name_en=t.name_en,
|
||||
level=t.level,
|
||||
@@ -164,6 +175,9 @@ async def list_expertise_categories(
|
||||
ExpertiseCategoryOut(
|
||||
id=t.id,
|
||||
key=t.key,
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
name_i18n=t.name_i18n if t.name_i18n else {},
|
||||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
||||
name_hu=t.name_hu,
|
||||
name_en=t.name_en,
|
||||
category=t.category,
|
||||
|
||||
@@ -42,6 +42,7 @@ from .identity.social import ServiceProvider, Vote, Competition, UserScore, Serv
|
||||
|
||||
# 9. Rendszer, Gamification és egyebek
|
||||
from .gamification.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger, UserContribution, Season
|
||||
from .gamification.validation_rule import ValidationRule
|
||||
|
||||
from .system.system import SystemParameter, ParameterScope, InternalNotification, SystemServiceStaging
|
||||
from .system.rbac import SystemRole, SystemPermission, SystemRolePermission
|
||||
@@ -75,7 +76,7 @@ __all__ = [
|
||||
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
|
||||
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
|
||||
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
|
||||
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution",
|
||||
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution", "ValidationRule",
|
||||
|
||||
"SystemParameter", "ParameterScope", "InternalNotification",
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/core_logic.py
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime # Python saját típusa a típusjelöléshez
|
||||
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float
|
||||
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import func
|
||||
@@ -243,11 +243,17 @@ class ServiceCatalog(Base):
|
||||
Tábla: system.service_catalog
|
||||
"""
|
||||
__tablename__ = "service_catalog"
|
||||
__table_args__ = {"schema": "system"}
|
||||
__table_args__ = (
|
||||
Index('idx_service_catalog_name_i18n', 'name_i18n', postgresql_using='gin'),
|
||||
{"schema": "system"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT'
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(String)
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
|
||||
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
@@ -9,6 +9,7 @@ from .gamification import (
|
||||
UserContribution,
|
||||
Season,
|
||||
)
|
||||
from .validation_rule import ValidationRule
|
||||
|
||||
__all__ = [
|
||||
"PointRule",
|
||||
@@ -19,4 +20,5 @@ __all__ = [
|
||||
"UserBadge",
|
||||
"UserContribution",
|
||||
"Season",
|
||||
"ValidationRule",
|
||||
]
|
||||
53
backend/app/models/gamification/validation_rule.py
Normal file
53
backend/app/models/gamification/validation_rule.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/gamification/validation_rule.py
|
||||
"""
|
||||
ValidationRule model — Gamification schema.
|
||||
|
||||
Stores dynamic global provider validation rules (thresholds) that drive
|
||||
the ValidationEngine. Rules are key-value pairs with integer values,
|
||||
seeded at initialization and editable via the admin panel.
|
||||
|
||||
Schema: gamification.validation_rules
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ValidationRule(Base):
|
||||
"""
|
||||
Global provider validation rules / thresholds.
|
||||
|
||||
Each row defines a single rule_key → rule_value mapping used by
|
||||
the ValidationEngine to compute trust scores and auto-approval
|
||||
thresholds for service providers.
|
||||
|
||||
Seed data (base rules):
|
||||
BOT_BASE_SCORE = 20 # Default trust score for robot-discovered entries
|
||||
FIRST_ENTRY_SCORE = 40 # Score awarded for the first user entry
|
||||
USER_CONFIRM_BASE = 20 # Base score per user confirmation vote
|
||||
AUTO_APPROVE_THRESHOLD = 80 # Minimum validation_level to auto-promote
|
||||
"""
|
||||
__tablename__ = "validation_rules"
|
||||
__table_args__ = {"schema": "gamification", "extend_existing": True}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
rule_key: Mapped[str] = mapped_column(
|
||||
String(100), unique=True, nullable=False, index=True,
|
||||
comment="Unique rule identifier, e.g. 'BOT_BASE_SCORE'"
|
||||
)
|
||||
rule_value: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
comment="Integer value for this rule (score, threshold, etc.)"
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True,
|
||||
comment="Human-readable description of what this rule controls"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), onupdate=func.now()
|
||||
)
|
||||
@@ -57,7 +57,7 @@ class Address(Base):
|
||||
# Robot és térképes funkciók számára
|
||||
latitude: Mapped[Optional[float]] = mapped_column(Float)
|
||||
longitude: Mapped[Optional[float]] = mapped_column(Float)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), default=func.now())
|
||||
|
||||
# Kapcsolat az irányítószám táblához (zip/city lekéréshez)
|
||||
postal_code: Mapped[Optional["GeoPostalCode"]] = relationship("GeoPostalCode", lazy="joined")
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, TYPE_CHECKING
|
||||
from sqlalchemy import String, Integer, ForeignKey, DateTime, Boolean, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID, JSONB, ARRAY
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.identity.address import Address
|
||||
|
||||
class ModerationStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
@@ -34,7 +37,12 @@ class ServiceProvider(Base):
|
||||
address: Mapped[str] = mapped_column(String, nullable=False)
|
||||
category: Mapped[Optional[str]] = mapped_column(String)
|
||||
|
||||
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők ===
|
||||
# === P0 ADDRESS UNIFICATION: Normalizált cím FK a system.addresses táblához ===
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
|
||||
)
|
||||
|
||||
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők (DEPRECATED - Phase 3-ban törlendő) ===
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||
@@ -68,6 +76,11 @@ class ServiceProvider(Base):
|
||||
JSONB, server_default=text("'{}'::jsonb"), nullable=True
|
||||
)
|
||||
|
||||
# === P0 ADDRESS UNIFICATION: Kapcsolat a normalizált címhez ===
|
||||
address_rel: Mapped[Optional["Address"]] = relationship(
|
||||
"Address", foreign_keys=[address_id], lazy="selectin"
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class Vote(Base):
|
||||
|
||||
@@ -35,7 +35,7 @@ class ServiceProfile(Base):
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketplace.service_profiles.id"))
|
||||
|
||||
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
||||
location: Mapped[Any] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), index=True)
|
||||
location: Mapped[Optional[Any]] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), nullable=True, index=True)
|
||||
|
||||
status: Mapped[ServiceStatus] = mapped_column(
|
||||
SQLEnum(ServiceStatus, name="service_status", schema="marketplace"),
|
||||
@@ -105,6 +105,7 @@ class ExpertiseTag(Base):
|
||||
__tablename__ = "expertise_tags"
|
||||
__table_args__ = (
|
||||
Index('idx_expertise_path', 'path'),
|
||||
Index('idx_expertise_tags_name_i18n', 'name_i18n', postgresql_using='gin'),
|
||||
{"schema": "marketplace"}
|
||||
)
|
||||
|
||||
@@ -112,6 +113,9 @@ class ExpertiseTag(Base):
|
||||
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
name_hu: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
name_en: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
|
||||
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
name_translations: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from sqlalchemy import String, Integer, DateTime, text, Boolean, Float, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base # MB 2.0 Standard: Központi bázis használata
|
||||
|
||||
@@ -75,10 +76,14 @@ class ServiceStaging(Base):
|
||||
# 9. ⚠️ EXTRA OSZLOP: audit_trail
|
||||
audit_trail: Mapped[Optional[dict]] = mapped_column(JSONB)
|
||||
|
||||
# 10. ⚠️ P0 BUGFIX: address_id — FK to system.addresses (UUID)
|
||||
# The column exists in the database but was missing from the model.
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True)
|
||||
|
||||
# Időbélyegek
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 10. ⚠️ EXTRA OSZLOP: updated_at
|
||||
# 11. ⚠️ EXTRA OSZLOP: updated_at
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
class DiscoveryParameter(Base):
|
||||
|
||||
@@ -165,10 +165,13 @@ class BodyTypeDictionary(Base):
|
||||
__tablename__ = "dict_body_types"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('vehicle_class', 'code', name='uix_body_type_class_code'),
|
||||
Index('idx_dict_body_types_name_i18n', 'name_i18n', postgresql_using='gin'),
|
||||
{"schema": "vehicle"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name_hu: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name_hu: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
|
||||
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||
@@ -35,8 +35,12 @@ class CategoryTreeNode(BaseModel):
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||
description_i18n: Optional[dict] = Field(None, description="Lokalizált leírás (JSONB)")
|
||||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
||||
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
|
||||
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
|
||||
level: int = 0
|
||||
path: Optional[str] = None
|
||||
is_official: bool = True
|
||||
@@ -53,8 +57,11 @@ class CategoryAutocompleteItem(BaseModel):
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
||||
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
|
||||
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
|
||||
level: int = 0
|
||||
path: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
@@ -73,8 +80,11 @@ class ExpertiseCategoryOut(BaseModel):
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
||||
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
|
||||
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
|
||||
category: Optional[str] = None
|
||||
level: int = 0
|
||||
parent_id: Optional[int] = None
|
||||
@@ -91,8 +101,11 @@ class CategoryInfo(BaseModel):
|
||||
szolgáltatásokat a kártyákon.
|
||||
"""
|
||||
id: int
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
||||
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
|
||||
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
|
||||
level: int = 0
|
||||
key: str
|
||||
|
||||
@@ -109,6 +122,10 @@ class ProviderSearchResult(BaseModel):
|
||||
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
|
||||
a backward compatibility miatt.
|
||||
|
||||
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||
- address_detail: Optional[AddressOut] — first-class citizen.
|
||||
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
|
||||
|
||||
FEATURE (2026-06-17): categories mező.
|
||||
- A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája.
|
||||
- Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez.
|
||||
@@ -118,12 +135,12 @@ class ProviderSearchResult(BaseModel):
|
||||
category: Optional[str] = None
|
||||
specialization: List[str] = Field(default_factory=list)
|
||||
categories: List[CategoryInfo] = Field(default_factory=list)
|
||||
city: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
|
||||
address: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.full_address_text instead")
|
||||
address_zip: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.zip instead")
|
||||
address_street_name: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_name instead")
|
||||
address_street_type: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_type instead")
|
||||
address_house_number: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.house_number instead")
|
||||
plus_code: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
@@ -134,7 +151,7 @@ class ProviderSearchResult(BaseModel):
|
||||
rating: Optional[float] = None
|
||||
supported_vehicle_classes: Optional[List[str]] = None
|
||||
specializations: Optional[dict] = None
|
||||
# P3: Egységes cím objektum (a flat mezők mellett)
|
||||
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
|
||||
address_detail: Optional[AddressOut] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -159,6 +176,10 @@ class ProviderQuickAddIn(BaseModel):
|
||||
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
|
||||
a backward compatibility miatt.
|
||||
|
||||
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||
- address_detail: Optional[AddressIn] — first-class citizen.
|
||||
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
|
||||
|
||||
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
|
||||
- A 4-level kategória rendszer bevezetésével a frontend a category_ids
|
||||
tömböt használja a kategóriák kiválasztására, nem a régi category_id-t.
|
||||
@@ -170,11 +191,11 @@ class ProviderQuickAddIn(BaseModel):
|
||||
category_id: Optional[int] = Field(None, ge=1, description="Elsődleges kategória ID (opcionális, backward compatibility)")
|
||||
category_ids: Optional[List[int]] = Field(None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)")
|
||||
new_tags: Optional[List[str]] = Field(None, description="User által gépelt új címkenevek (is_official=False, level=3)")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.zip instead")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Use address_detail.street_name instead")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Use address_detail.street_type instead")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.house_number instead")
|
||||
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
||||
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")
|
||||
@@ -186,7 +207,7 @@ class ProviderQuickAddIn(BaseModel):
|
||||
specializations: Optional[dict] = Field(
|
||||
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
|
||||
)
|
||||
# P3: Egységes cím objektum (a flat mezők mellett)
|
||||
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
|
||||
address_detail: Optional[AddressIn] = None
|
||||
|
||||
|
||||
@@ -211,16 +232,20 @@ class ProviderUpdateIn(BaseModel):
|
||||
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
|
||||
a backward compatibility miatt.
|
||||
|
||||
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||
- address_detail: Optional[AddressIn] — first-class citizen.
|
||||
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
|
||||
|
||||
4-Level Category (2026-06-17):
|
||||
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3)
|
||||
- new_tags: A user által gépelt, új (még nem létező) címkék listája
|
||||
"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.zip instead")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Use address_detail.street_name instead")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Use address_detail.street_type instead")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.house_number instead")
|
||||
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
||||
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")
|
||||
@@ -238,7 +263,7 @@ class ProviderUpdateIn(BaseModel):
|
||||
specializations: Optional[dict] = Field(
|
||||
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
|
||||
)
|
||||
# P3: Egységes cím objektum (a flat mezők mellett)
|
||||
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
|
||||
address_detail: Optional[AddressIn] = None
|
||||
|
||||
|
||||
|
||||
131
backend/app/scripts/migrate_i18n_jsonb.py
Normal file
131
backend/app/scripts/migrate_i18n_jsonb.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
i18n JSONB Migration Script (Phase 1)
|
||||
Migrates old name_hu/name_en columns to name_i18n JSONB format.
|
||||
Run INSIDE the sf_api container:
|
||||
docker exec sf_api python3 /app/backend/app/scripts/migrate_i18n_jsonb.py
|
||||
|
||||
IMPORTANT: Run this AFTER the new JSONB columns have been added by sync_engine.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
TABLES = [
|
||||
{
|
||||
"schema": "marketplace",
|
||||
"table": "expertise_tags",
|
||||
"name_cols": ["name_hu", "name_en"],
|
||||
"name_translations_col": "name_translations",
|
||||
"desc_cols": ["description"],
|
||||
"target_name_col": "name_i18n",
|
||||
"target_desc_col": "description_i18n",
|
||||
},
|
||||
{
|
||||
"schema": "vehicle",
|
||||
"table": "dict_body_types",
|
||||
"name_cols": ["name_hu"],
|
||||
"name_translations_col": None,
|
||||
"desc_cols": [],
|
||||
"target_name_col": "name_i18n",
|
||||
"target_desc_col": None,
|
||||
},
|
||||
{
|
||||
"schema": "system",
|
||||
"table": "service_catalog",
|
||||
"name_cols": ["name"],
|
||||
"name_translations_col": None,
|
||||
"desc_cols": ["description"],
|
||||
"target_name_col": "name_i18n",
|
||||
"target_desc_col": "description_i18n",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def migrate_table(config: dict):
|
||||
schema = config["schema"]
|
||||
table = config["table"]
|
||||
target_name = config["target_name_col"]
|
||||
target_desc = config["target_desc_col"]
|
||||
name_cols = config["name_cols"]
|
||||
desc_cols = config["desc_cols"]
|
||||
trans_col = config["name_translations_col"]
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Migrating: {schema}.{table}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
select_cols = ["id"] + name_cols + desc_cols
|
||||
if trans_col:
|
||||
select_cols.append(trans_col)
|
||||
|
||||
stmt = text(
|
||||
f"SELECT {', '.join(select_cols)} FROM {schema}.{table} "
|
||||
f"WHERE {target_name} IS NULL OR {target_name} = '{{\"hu\": \"\"}}'::jsonb"
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.fetchall()
|
||||
|
||||
if not rows:
|
||||
print(f" ✅ No rows need migration in {schema}.{table}")
|
||||
return
|
||||
|
||||
updated = 0
|
||||
for row in rows:
|
||||
row_dict = row._mapping
|
||||
|
||||
# Build name_i18n
|
||||
name_i18n = {}
|
||||
if len(name_cols) >= 1 and row_dict.get(name_cols[0]):
|
||||
name_i18n["hu"] = row_dict[name_cols[0]]
|
||||
if len(name_cols) >= 2 and row_dict.get(name_cols[1]):
|
||||
name_i18n["en"] = row_dict[name_cols[1]]
|
||||
|
||||
# Merge with existing name_translations
|
||||
if trans_col and row_dict.get(trans_col):
|
||||
if isinstance(row_dict[trans_col], dict):
|
||||
name_i18n.update(row_dict[trans_col])
|
||||
|
||||
# Build description_i18n
|
||||
desc_i18n = None
|
||||
if desc_cols and row_dict.get(desc_cols[0]):
|
||||
desc_i18n = {"hu": row_dict[desc_cols[0]]}
|
||||
|
||||
# Update
|
||||
update_cols = [f"{target_name} = :name_val"]
|
||||
params = {"name_val": json.dumps(name_i18n), "row_id": row_dict["id"]}
|
||||
|
||||
if target_desc and desc_i18n:
|
||||
update_cols.append(f"{target_desc} = :desc_val")
|
||||
params["desc_val"] = json.dumps(desc_i18n)
|
||||
|
||||
update_sql = f"UPDATE {schema}.{table} SET {', '.join(update_cols)} WHERE id = :row_id"
|
||||
await db.execute(text(update_sql), params)
|
||||
updated += 1
|
||||
|
||||
await db.commit()
|
||||
print(f" ✅ Migrated {updated} rows in {schema}.{table}")
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print(" i18n JSONB Migration Script (Phase 1)")
|
||||
print("=" * 70)
|
||||
|
||||
for table_config in TABLES:
|
||||
await migrate_table(table_config)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" ✅ Migration complete!")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
259
backend/app/scripts/migrate_provider_addresses.py
Normal file
259
backend/app/scripts/migrate_provider_addresses.py
Normal file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
P0 ADDRESS UNIFICATION - Phase 1: ServiceProvider Address Migration
|
||||
|
||||
Migrates flat address columns (city, address_zip, address_street_name, etc.)
|
||||
from marketplace.service_providers into the centralized system.addresses table.
|
||||
|
||||
Logic:
|
||||
1. Fetch all ServiceProvider records where address_id IS NULL
|
||||
but at least one flat address field is non-empty.
|
||||
2. For each record, construct an AddressIn schema from the flat data.
|
||||
3. Call AddressManager.create_or_update(db, None, address_data) to create
|
||||
a new central address record.
|
||||
4. Assign the returned Address UUID to provider.address_id.
|
||||
5. Commit in batches with proper error handling and logging.
|
||||
|
||||
Usage:
|
||||
docker exec sf_api python3 /app/backend/app/scripts/migrate_provider_addresses.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
# Ensure the backend package is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# asyncpg needs plain "postgresql://" scheme
|
||||
_raw_url = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://service_finder_app:AppSafePass_2026@db:5432/service_finder"
|
||||
)
|
||||
DATABASE_URL = _raw_url.replace("+asyncpg", "")
|
||||
|
||||
# Batch size for commits
|
||||
BATCH_SIZE = 100
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
logger = logging.getLogger("migrate_provider_addresses")
|
||||
|
||||
|
||||
def _build_full_address_text(row: dict) -> Optional[str]:
|
||||
"""Build a human-readable full address from flat fields."""
|
||||
parts = []
|
||||
if row.get("address_zip"):
|
||||
parts.append(row["address_zip"])
|
||||
if row.get("city"):
|
||||
parts.append(row["city"])
|
||||
street_parts = []
|
||||
if row.get("address_street_name"):
|
||||
street_parts.append(row["address_street_name"])
|
||||
if row.get("address_street_type"):
|
||||
street_parts.append(row["address_street_type"])
|
||||
if row.get("address_house_number"):
|
||||
street_parts.append(row["address_house_number"])
|
||||
if street_parts:
|
||||
parts.append(" ".join(street_parts))
|
||||
return ", ".join(parts) if parts else None
|
||||
|
||||
|
||||
def _has_address_data(row: dict) -> bool:
|
||||
"""Check if the provider has any flat address data worth migrating."""
|
||||
return bool(
|
||||
row.get("city")
|
||||
or row.get("address_zip")
|
||||
or row.get("address_street_name")
|
||||
or row.get("address_house_number")
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 70)
|
||||
logger.info("🔧 P0 ADDRESS UNIFICATION - Phase 1: ServiceProvider Migration")
|
||||
logger.info("=" * 70)
|
||||
logger.info(f"Started at: {datetime.now(timezone.utc).isoformat()}")
|
||||
logger.info(f"Database URL: {DATABASE_URL.replace('AppSafePass_2026', '****')}")
|
||||
logger.info(f"Batch size: {BATCH_SIZE}")
|
||||
logger.info("")
|
||||
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
|
||||
try:
|
||||
# ── Step 1: Count providers needing migration ──
|
||||
logger.info("📋 Step 1: Counting providers with flat address data but no address_id")
|
||||
count_row = await conn.fetchrow("""
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM marketplace.service_providers
|
||||
WHERE address_id IS NULL
|
||||
AND (
|
||||
city IS NOT NULL AND city != ''
|
||||
OR address_zip IS NOT NULL AND address_zip != ''
|
||||
OR address_street_name IS NOT NULL AND address_street_name != ''
|
||||
OR address_house_number IS NOT NULL AND address_house_number != ''
|
||||
)
|
||||
""")
|
||||
total = count_row["cnt"]
|
||||
logger.info(f" → Found {total} provider(s) to migrate")
|
||||
logger.info("")
|
||||
|
||||
if total == 0:
|
||||
logger.info("✅ No providers need migration. Exiting.")
|
||||
return
|
||||
|
||||
# ── Step 2: Fetch all providers needing migration ──
|
||||
logger.info("📋 Step 2: Fetching provider records")
|
||||
rows = await conn.fetch("""
|
||||
SELECT id, name, city, address_zip,
|
||||
address_street_name, address_street_type, address_house_number,
|
||||
plus_code
|
||||
FROM marketplace.service_providers
|
||||
WHERE address_id IS NULL
|
||||
AND (
|
||||
city IS NOT NULL AND city != ''
|
||||
OR address_zip IS NOT NULL AND address_zip != ''
|
||||
OR address_street_name IS NOT NULL AND address_street_name != ''
|
||||
OR address_house_number IS NOT NULL AND address_house_number != ''
|
||||
)
|
||||
ORDER BY id
|
||||
""")
|
||||
logger.info(f" → Fetched {len(rows)} record(s)")
|
||||
logger.info("")
|
||||
|
||||
# ── Step 3: Migrate each provider ──
|
||||
logger.info("📋 Step 3: Migrating addresses to system.addresses")
|
||||
migrated_count = 0
|
||||
error_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for i, row in enumerate(rows, start=1):
|
||||
provider_id = row["id"]
|
||||
provider_name = row["name"]
|
||||
|
||||
if not _has_address_data(row):
|
||||
logger.info(f" [{i}/{total}] Provider #{provider_id} '{provider_name}': "
|
||||
f"no address data → SKIP")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# Build the full_address_text
|
||||
full_text = _build_full_address_text(row)
|
||||
|
||||
# Insert into system.addresses
|
||||
new_address_id = uuid.uuid4()
|
||||
await conn.execute("""
|
||||
INSERT INTO system.addresses
|
||||
(id, street_name, street_type, house_number,
|
||||
full_address_text, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
new_address_id,
|
||||
row.get("address_street_name"),
|
||||
row.get("address_street_type"),
|
||||
row.get("address_house_number"),
|
||||
full_text,
|
||||
)
|
||||
|
||||
# Resolve postal code if zip+city are present
|
||||
if row.get("address_zip") and row.get("city"):
|
||||
# Find or create GeoPostalCode
|
||||
postal = await conn.fetchrow("""
|
||||
SELECT id FROM system.geo_postal_codes
|
||||
WHERE zip_code = $1
|
||||
""", row["address_zip"])
|
||||
|
||||
if postal:
|
||||
postal_code_id = postal["id"]
|
||||
else:
|
||||
postal_code_id = await conn.fetchval("""
|
||||
INSERT INTO system.geo_postal_codes
|
||||
(zip_code, city, country_code)
|
||||
VALUES ($1, $2, 'HU')
|
||||
RETURNING id
|
||||
""", row["address_zip"], row["city"])
|
||||
|
||||
# Update the address with postal_code_id
|
||||
await conn.execute("""
|
||||
UPDATE system.addresses
|
||||
SET postal_code_id = $1
|
||||
WHERE id = $2
|
||||
""", postal_code_id, new_address_id)
|
||||
|
||||
# Update the provider with the new address_id
|
||||
await conn.execute("""
|
||||
UPDATE marketplace.service_providers
|
||||
SET address_id = $1
|
||||
WHERE id = $2
|
||||
""", new_address_id, provider_id)
|
||||
|
||||
migrated_count += 1
|
||||
|
||||
if i % BATCH_SIZE == 0 or i == total:
|
||||
logger.info(
|
||||
f" [{i}/{total}] Progress: {migrated_count} migrated, "
|
||||
f"{error_count} errors, {skipped_count} skipped"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f" [{i}/{total}] Provider #{provider_id} '{provider_name}': "
|
||||
f"ERROR - {e}"
|
||||
)
|
||||
|
||||
# ── Summary ──
|
||||
logger.info("")
|
||||
logger.info("=" * 70)
|
||||
logger.info("📊 MIGRATION SUMMARY")
|
||||
logger.info("=" * 70)
|
||||
logger.info(f" Total providers processed: {total}")
|
||||
logger.info(f" Successfully migrated: {migrated_count}")
|
||||
logger.info(f" Errors: {error_count}")
|
||||
logger.info(f" Skipped (no address data): {skipped_count}")
|
||||
|
||||
# ── Step 4: Verify ──
|
||||
logger.info("")
|
||||
logger.info("📋 Step 4: Verification")
|
||||
remaining = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM marketplace.service_providers
|
||||
WHERE address_id IS NULL
|
||||
AND (
|
||||
city IS NOT NULL AND city != ''
|
||||
OR address_zip IS NOT NULL AND address_zip != ''
|
||||
OR address_street_name IS NOT NULL AND address_street_name != ''
|
||||
OR address_house_number IS NOT NULL AND address_house_number != ''
|
||||
)
|
||||
""")
|
||||
total_providers = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM marketplace.service_providers"
|
||||
)
|
||||
with_address = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM marketplace.service_providers WHERE address_id IS NOT NULL"
|
||||
)
|
||||
logger.info(f" Total providers: {total_providers}")
|
||||
logger.info(f" Providers WITH address_id: {with_address}")
|
||||
logger.info(f" Providers still NULL: {remaining}")
|
||||
logger.info("")
|
||||
logger.info("✅ Phase 1 migration complete!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"💥 FATAL ERROR: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
90
backend/app/scripts/seed_dismantler_category.py
Normal file
90
backend/app/scripts/seed_dismantler_category.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Seed script: Beszúrja az "Autóbontó / Használt alkatrész" kategóriát
|
||||
a marketplace.expertise_tags táblába, ha még nem létezik.
|
||||
|
||||
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
|
||||
|
||||
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_dismantler_category.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select, func
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.marketplace.service import ExpertiseTag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DISMANTLER_CATEGORY = {
|
||||
"key": "autobonto_hasznalt_alkatresz",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Autóbontó / Használt alkatrész", "en": "Car Dismantler / Used Parts"},
|
||||
"name_hu": "Autóbontó / Használt alkatrész",
|
||||
"name_en": "Car Dismantler / Used Parts",
|
||||
"category": "parts",
|
||||
"search_keywords": [
|
||||
"bontó", "autóbontó", "bontás", "használt alkatrész",
|
||||
"dismantler", "used parts", "scrap yard", "break yard",
|
||||
"alkatrész bontás", "roncs", "roncsautó",
|
||||
],
|
||||
"description": "Használt gépjármű alkatrészek értékesítése, autóbontás, roncsautó felvásárlás",
|
||||
}
|
||||
|
||||
|
||||
async def seed_dismantler_category():
|
||||
"""Beszúrja az Autóbontó kategóriát, ha még nem létezik."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# Ellenőrizzük, hogy létezik-e már
|
||||
stmt = select(ExpertiseTag).where(
|
||||
ExpertiseTag.key == DISMANTLER_CATEGORY["key"]
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
print(
|
||||
f"✅ Az 'Autóbontó / Használt alkatrész' kategória "
|
||||
f"már létezik (ID: {existing.id}). Nincs szükség seed-elésre."
|
||||
)
|
||||
return
|
||||
|
||||
# Beszúrás
|
||||
tag = ExpertiseTag(
|
||||
key=DISMANTLER_CATEGORY["key"],
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
name_i18n=DISMANTLER_CATEGORY.get("name_i18n", {"hu": DISMANTLER_CATEGORY["name_hu"], "en": DISMANTLER_CATEGORY["name_en"]}),
|
||||
name_hu=DISMANTLER_CATEGORY["name_hu"],
|
||||
name_en=DISMANTLER_CATEGORY["name_en"],
|
||||
category=DISMANTLER_CATEGORY["category"],
|
||||
search_keywords=DISMANTLER_CATEGORY["search_keywords"],
|
||||
description=DISMANTLER_CATEGORY["description"],
|
||||
is_official=True,
|
||||
discovery_points=10,
|
||||
usage_count=0,
|
||||
)
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
print(
|
||||
f"✅ Sikeresen beszúrva az 'Autóbontó / Használt alkatrész' "
|
||||
f"kategória (ID: {tag.id})."
|
||||
)
|
||||
|
||||
# Ellenőrzés
|
||||
count_result = await db.execute(
|
||||
select(func.count(ExpertiseTag.id))
|
||||
)
|
||||
final_count = count_result.scalar()
|
||||
print(f"📊 Összes rekord az expertise_tags táblában: {final_count}")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Hiba a seed-elés során: {e}")
|
||||
print(f"❌ Hiba: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
asyncio.run(seed_dismantler_category())
|
||||
@@ -16,6 +16,8 @@ logger = logging.getLogger(__name__)
|
||||
BASE_CATEGORIES = [
|
||||
{
|
||||
"key": "auto_szerelo",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Autószerelő", "en": "Car Mechanic"},
|
||||
"name_hu": "Autószerelő",
|
||||
"name_en": "Car Mechanic",
|
||||
"category": "vehicle_service",
|
||||
@@ -24,6 +26,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "motor_szerelo",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Motorkerékpár szerelő", "en": "Motorcycle Mechanic"},
|
||||
"name_hu": "Motorkerékpár szerelő",
|
||||
"name_en": "Motorcycle Mechanic",
|
||||
"category": "vehicle_service",
|
||||
@@ -32,6 +36,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "gumiszerviz",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Gumiszerviz", "en": "Tire Service"},
|
||||
"name_hu": "Gumiszerviz",
|
||||
"name_en": "Tire Service",
|
||||
"category": "vehicle_service",
|
||||
@@ -40,6 +46,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "karosszerialakatos",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Karosszérialakatos", "en": "Body Shop"},
|
||||
"name_hu": "Karosszérialakatos",
|
||||
"name_en": "Body Shop",
|
||||
"category": "body_paint",
|
||||
@@ -48,6 +56,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "fényező",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Fényező", "en": "Painter"},
|
||||
"name_hu": "Fényező",
|
||||
"name_en": "Painter",
|
||||
"category": "body_paint",
|
||||
@@ -56,6 +66,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "autómentő",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Autómentő / Motormentő", "en": "Tow Truck / Roadside Assistance"},
|
||||
"name_hu": "Autómentő / Motormentő",
|
||||
"name_en": "Tow Truck / Roadside Assistance",
|
||||
"category": "roadside",
|
||||
@@ -64,6 +76,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "benzinkút",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Benzinkút", "en": "Gas Station"},
|
||||
"name_hu": "Benzinkút",
|
||||
"name_en": "Gas Station",
|
||||
"category": "fuel",
|
||||
@@ -72,6 +86,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "alkatrész_kereskedés",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Alkatrész kereskedés", "en": "Parts Store"},
|
||||
"name_hu": "Alkatrész kereskedés",
|
||||
"name_en": "Parts Store",
|
||||
"category": "parts",
|
||||
@@ -80,6 +96,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "vizsgaállomás",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Vizsgaállomás", "en": "Inspection Station"},
|
||||
"name_hu": "Vizsgaállomás",
|
||||
"name_en": "Inspection Station",
|
||||
"category": "inspection",
|
||||
@@ -88,6 +106,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "autókozmetika",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Autókozmetika", "en": "Car Detailing"},
|
||||
"name_hu": "Autókozmetika",
|
||||
"name_en": "Car Detailing",
|
||||
"category": "detailing",
|
||||
@@ -96,6 +116,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "egyéb",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Egyéb szolgáltató", "en": "Other Service"},
|
||||
"name_hu": "Egyéb szolgáltató",
|
||||
"name_en": "Other Service",
|
||||
"category": "other",
|
||||
@@ -123,6 +145,8 @@ async def seed_expertise_tags():
|
||||
for cat in BASE_CATEGORIES:
|
||||
tag = ExpertiseTag(
|
||||
key=cat["key"],
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
|
||||
name_hu=cat["name_hu"],
|
||||
name_en=cat["name_en"],
|
||||
category=cat["category"],
|
||||
|
||||
129
backend/app/scripts/seed_gas_station.py
Normal file
129
backend/app/scripts/seed_gas_station.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Seed script: Beszúrja a hiányzó "Shop / Kisbolt" (Convenience Store) kategóriát
|
||||
a marketplace.expertise_tags táblába, az "Üzemanyag és Töltőállomás" (ID=755)
|
||||
Level 1 szülő alá.
|
||||
|
||||
Meglévő kategóriák (már léteznek, nem hozzuk létre újra):
|
||||
- ID=755: Üzemanyag és Töltőállomás (Level 1)
|
||||
- ID=756: Benzinkút (Level 2)
|
||||
- ID=757: Elektromos Töltőállomás (Level 2)
|
||||
|
||||
Hiányzó kategória (ezt hozza létre a script):
|
||||
- Shop / Kisbolt (Level 2, parent_id=755)
|
||||
|
||||
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
|
||||
|
||||
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_gas_station.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select, func, text
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.marketplace.service import ExpertiseTag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A meglévő "Üzemanyag és Töltőállomás" szülő ID-ja
|
||||
FUEL_STATION_PARENT_ID = 755
|
||||
|
||||
CATEGORIES_TO_SEED = [
|
||||
{
|
||||
"key": "shop_kisbolt",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Shop / Kisbolt", "en": "Shop / Convenience Store"},
|
||||
"name_hu": "Shop / Kisbolt",
|
||||
"name_en": "Shop / Convenience Store",
|
||||
"category": "fuel",
|
||||
"level": 2,
|
||||
"parent_id": FUEL_STATION_PARENT_ID,
|
||||
"path": "uzemanyag_toltoallomas/shop_kisbolt",
|
||||
"search_keywords": [
|
||||
"shop", "kisbolt", "convenience", "vegyesbolt",
|
||||
"benzinkút shop", "élelmiszer", "snack", "ital",
|
||||
],
|
||||
"description": "Benzinkúton üzemelő kisbolt, shop, convenience store",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_gas_station_categories():
|
||||
"""Beszúrja a hiányzó kategóriákat, ha még nem léteznek."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
for cat in CATEGORIES_TO_SEED:
|
||||
# Ellenőrizzük, hogy létezik-e már
|
||||
stmt = select(ExpertiseTag).where(
|
||||
ExpertiseTag.key == cat["key"]
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
print(
|
||||
f"✅ A '{cat['name_hu']}' kategória "
|
||||
f"már létezik (ID: {existing.id}). Nincs szükség seed-elésre."
|
||||
)
|
||||
continue
|
||||
|
||||
# Beszúrás
|
||||
tag = ExpertiseTag(
|
||||
key=cat["key"],
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
|
||||
name_hu=cat["name_hu"],
|
||||
name_en=cat["name_en"],
|
||||
category=cat["category"],
|
||||
level=cat["level"],
|
||||
parent_id=cat["parent_id"],
|
||||
path=cat["path"],
|
||||
search_keywords=cat["search_keywords"],
|
||||
description=cat["description"],
|
||||
is_official=True,
|
||||
discovery_points=10,
|
||||
usage_count=0,
|
||||
)
|
||||
db.add(tag)
|
||||
await db.flush()
|
||||
await db.refresh(tag)
|
||||
print(
|
||||
f"✅ Sikeresen beszúrva a '{cat['name_hu']}' "
|
||||
f"kategória (ID: {tag.id})."
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Ellenőrzés
|
||||
count_result = await db.execute(
|
||||
select(func.count(ExpertiseTag.id))
|
||||
)
|
||||
final_count = count_result.scalar()
|
||||
print(f"📊 Összes rekord az expertise_tags táblában: {final_count}")
|
||||
|
||||
# Listázzuk a benzinkút kategóriákat
|
||||
print("\n📋 Benzinkút kategóriák:")
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id, key, name_hu, name_en, level, parent_id, path
|
||||
FROM marketplace.expertise_tags
|
||||
WHERE parent_id = :pid OR id = :pid
|
||||
ORDER BY level, id
|
||||
"""),
|
||||
{"pid": FUEL_STATION_PARENT_ID},
|
||||
)
|
||||
for row in result:
|
||||
print(f" ID={row.id}, key={row.key}, name_hu={row.name_hu}, level={row.level}")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Hiba a seed-elés során: {e}")
|
||||
print(f"❌ Hiba: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
asyncio.run(seed_gas_station_categories())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
62
backend/app/scripts/seed_validation_rules.py
Normal file
62
backend/app/scripts/seed_validation_rules.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/scripts/seed_validation_rules.py
|
||||
"""
|
||||
Seed script for gamification.validation_rules table.
|
||||
|
||||
Inserts the base validation rules required by the P0 Architecture:
|
||||
|
||||
BOT_BASE_SCORE = 20 # Default trust score for robot-discovered entries
|
||||
FIRST_ENTRY_SCORE = 40 # Score awarded for the first user entry
|
||||
USER_CONFIRM_BASE = 20 # Base score per user confirmation vote
|
||||
AUTO_APPROVE_THRESHOLD = 80 # Minimum validation_level to auto-promote
|
||||
|
||||
Usage:
|
||||
docker exec -it sf_api python -m app.scripts.seed_validation_rules
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.gamification.validation_rule import ValidationRule
|
||||
from sqlalchemy import select
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
|
||||
logger = logging.getLogger("SeedValidationRules")
|
||||
|
||||
BASE_RULES = [
|
||||
{"rule_key": "BOT_BASE_SCORE", "rule_value": 20, "description": "Default trust score for robot-discovered service entries"},
|
||||
{"rule_key": "FIRST_ENTRY_SCORE", "rule_value": 40, "description": "Score awarded for the first user-submitted service entry"},
|
||||
{"rule_key": "USER_CONFIRM_BASE", "rule_value": 20, "description": "Base score per user confirmation vote (level multiplier added)"},
|
||||
{"rule_key": "AUTO_APPROVE_THRESHOLD", "rule_value": 80, "description": "Minimum validation_level required for auto-promotion to provider"},
|
||||
]
|
||||
|
||||
|
||||
async def seed():
|
||||
async with AsyncSessionLocal() as db:
|
||||
for rule_data in BASE_RULES:
|
||||
# Check if rule already exists
|
||||
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_data["rule_key"])
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info(f"Rule '{rule_data['rule_key']}' already exists (value={existing.rule_value}), skipping.")
|
||||
continue
|
||||
|
||||
rule = ValidationRule(
|
||||
rule_key=rule_data["rule_key"],
|
||||
rule_value=rule_data["rule_value"],
|
||||
description=rule_data["description"],
|
||||
)
|
||||
db.add(rule)
|
||||
logger.info(f"Created rule '{rule_data['rule_key']}' = {rule_data['rule_value']}")
|
||||
|
||||
await db.commit()
|
||||
logger.info("✅ Validation rules seeded successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
360
backend/app/services/validation_engine.py
Normal file
360
backend/app/services/validation_engine.py
Normal file
@@ -0,0 +1,360 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/validation_engine.py
|
||||
"""
|
||||
ValidationEngine — Gamified Provider Validation & Promotion Engine.
|
||||
|
||||
P0 Architecture: Dynamic rule-based validation scoring for service providers.
|
||||
|
||||
Responsibilities:
|
||||
1. Fetch dynamic validation rules from gamification.validation_rules table.
|
||||
2. Compute validation scores for ServiceStaging entries based on:
|
||||
- Admin override (score = 100)
|
||||
- User level from gamification.user_stats (weighted vote)
|
||||
- Robot base score (BOT_BASE_SCORE)
|
||||
3. Log validation events in marketplace.provider_validations.
|
||||
4. Auto-promote staging entries to full Organization + ServiceProfile
|
||||
when validation_level >= AUTO_APPROVE_THRESHOLD.
|
||||
|
||||
Usage:
|
||||
engine = ValidationEngine()
|
||||
await engine.process_staging_score(db, staging, user_id=42)
|
||||
await engine.process_staging_score(db, staging, is_admin=True)
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import (
|
||||
ValidationRule,
|
||||
ServiceStaging,
|
||||
UserStats,
|
||||
Organization,
|
||||
ServiceProfile,
|
||||
Address,
|
||||
)
|
||||
from app.models.identity.social import ProviderValidation
|
||||
from app.schemas.address import AddressIn
|
||||
from app.services.address_service import create_address
|
||||
|
||||
logger = logging.getLogger("ValidationEngine")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Default fallback values if the validation_rules table is empty
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
DEFAULT_RULES: dict[str, int] = {
|
||||
"BOT_BASE_SCORE": 20,
|
||||
"FIRST_ENTRY_SCORE": 40,
|
||||
"USER_CONFIRM_BASE": 20,
|
||||
"AUTO_APPROVE_THRESHOLD": 80,
|
||||
}
|
||||
|
||||
|
||||
class ValidationEngine:
|
||||
"""
|
||||
Gamified Validation Engine for service provider staging entries.
|
||||
|
||||
Operates on ServiceStaging records, computing validation_level scores
|
||||
based on dynamic rules from the gamification.validation_rules table.
|
||||
"""
|
||||
|
||||
# ── Rule Loading ─────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def get_rules(db: AsyncSession) -> dict[str, int]:
|
||||
"""
|
||||
Fetch all active validation rules from the database.
|
||||
|
||||
Returns a dict of rule_key → rule_value. Falls back to DEFAULT_RULES
|
||||
for any key not found in the table, ensuring the engine always has
|
||||
sensible defaults even before seeding.
|
||||
"""
|
||||
stmt = select(ValidationRule)
|
||||
result = await db.execute(stmt)
|
||||
db_rules = result.scalars().all()
|
||||
|
||||
rules = dict(DEFAULT_RULES) # start with fallbacks
|
||||
for rule in db_rules:
|
||||
rules[rule.rule_key] = rule.rule_value
|
||||
|
||||
return rules
|
||||
|
||||
# ── Scoring ──────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def _get_user_weight(
|
||||
db: AsyncSession, user_id: int, rules: dict[str, int]
|
||||
) -> int:
|
||||
"""
|
||||
Calculate a user's vote weight based on their gamification level.
|
||||
|
||||
Uses gamification.user_stats.current_level as the multiplier.
|
||||
Formula: USER_CONFIRM_BASE + (current_level * 5)
|
||||
|
||||
If no UserStats record exists, returns USER_CONFIRM_BASE as fallback.
|
||||
"""
|
||||
base = rules.get("USER_CONFIRM_BASE", DEFAULT_RULES["USER_CONFIRM_BASE"])
|
||||
stmt = select(UserStats).where(UserStats.user_id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None:
|
||||
logger.warning(f"No UserStats found for user_id={user_id}, using base weight={base}")
|
||||
return base
|
||||
|
||||
level = stats.current_level or 1
|
||||
weight = base + (level * 5)
|
||||
logger.info(
|
||||
f"User {user_id} weight: base={base}, level={level}, total_weight={weight}"
|
||||
)
|
||||
return weight
|
||||
|
||||
# ── Validation Logging ───────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def _log_validation(
|
||||
db: AsyncSession,
|
||||
provider_id: int,
|
||||
voter_user_id: int,
|
||||
validation_type: str,
|
||||
weight: int,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> Optional[ProviderValidation]:
|
||||
"""
|
||||
Log a validation event in marketplace.provider_validations.
|
||||
|
||||
NOTE: ProviderValidation.provider_id references marketplace.service_providers.id,
|
||||
NOT marketplace.service_staging.id. This method should only be called
|
||||
AFTER promotion when a real ServiceProvider record exists.
|
||||
If the provider_id does not correspond to a valid service_providers row,
|
||||
the FK constraint will fail — in that case we log a warning and skip.
|
||||
|
||||
Uses INSERT ... ON CONFLICT DO NOTHING semantics via a pre-check
|
||||
to respect the UniqueConstraint(voter_user_id, provider_id).
|
||||
"""
|
||||
# Check if this user already validated this provider
|
||||
stmt = select(ProviderValidation).where(
|
||||
ProviderValidation.voter_user_id == voter_user_id,
|
||||
ProviderValidation.provider_id == provider_id,
|
||||
)
|
||||
existing = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if existing:
|
||||
logger.info(
|
||||
f"User {voter_user_id} already validated provider {provider_id} "
|
||||
f"(type={existing.validation_type}, weight={existing.weight})"
|
||||
)
|
||||
return existing
|
||||
|
||||
record = ProviderValidation(
|
||||
provider_id=provider_id,
|
||||
voter_user_id=voter_user_id,
|
||||
validation_type=validation_type,
|
||||
weight=weight,
|
||||
validation_metadata=metadata or {},
|
||||
)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"Logged validation: provider={provider_id}, user={voter_user_id}, "
|
||||
f"type={validation_type}, weight={weight}"
|
||||
)
|
||||
return record
|
||||
|
||||
# ── Promotion Logic ──────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def _promote_to_provider(
|
||||
db: AsyncSession, staging: ServiceStaging
|
||||
) -> tuple[Organization, ServiceProfile]:
|
||||
"""
|
||||
Promote a ServiceStaging entry to a full Organization + ServiceProfile.
|
||||
|
||||
Steps:
|
||||
1. Create an Address from staging data (via AddressManager).
|
||||
2. Create an Organization with org_type='service'.
|
||||
3. Create a ServiceProfile linked to the Organization.
|
||||
4. Set staging.status = 'approved'.
|
||||
|
||||
Returns:
|
||||
Tuple of (Organization, ServiceProfile).
|
||||
"""
|
||||
logger.info(
|
||||
f"Promoting staging {staging.id} ('{staging.name}') to provider..."
|
||||
)
|
||||
|
||||
# ── 1. Create Address ────────────────────────────────────────────────
|
||||
from datetime import datetime, timezone
|
||||
addr_in = AddressIn(
|
||||
city=staging.city or "",
|
||||
zip=staging.postal_code or "",
|
||||
full_address_text=staging.full_address or "",
|
||||
)
|
||||
# NOTE: created_at has no server_default in the DB, so we set it explicitly
|
||||
address = Address(
|
||||
id=uuid.uuid4(),
|
||||
full_address_text=addr_in.full_address_text,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
# Resolve postal code if zip and city provided
|
||||
if addr_in.zip and addr_in.city:
|
||||
from app.services.address_service import _resolve_postal_code
|
||||
postal_code_id = await _resolve_postal_code(db, addr_in.zip, addr_in.city)
|
||||
address.postal_code_id = postal_code_id
|
||||
db.add(address)
|
||||
await db.flush()
|
||||
|
||||
# ── 2. Create Organization ───────────────────────────────────────────
|
||||
now = datetime.now(timezone.utc)
|
||||
org = Organization(
|
||||
name=staging.name,
|
||||
full_name=staging.name,
|
||||
folder_slug=f"svc-{uuid.uuid4().hex[:12]}",
|
||||
org_type="service",
|
||||
address_id=address.id,
|
||||
status="active",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
# NOTE: Several columns have server_default in the model but NOT
|
||||
# in the actual DB, so we set them explicitly:
|
||||
created_at=now,
|
||||
first_registered_at=now,
|
||||
current_lifecycle_started_at=now,
|
||||
subscription_plan="FREE",
|
||||
base_asset_limit=1,
|
||||
purchased_extra_slots=0,
|
||||
lifecycle_index=1,
|
||||
is_ownership_transferable=True,
|
||||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||||
external_integration_config={},
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
|
||||
# ── 3. Create ServiceProfile ─────────────────────────────────────────
|
||||
from sqlalchemy import text as sa_text
|
||||
profile = ServiceProfile(
|
||||
organization_id=org.id,
|
||||
fingerprint=staging.fingerprint,
|
||||
status="active",
|
||||
trust_score=staging.trust_score or 20,
|
||||
is_verified=True,
|
||||
contact_phone=staging.contact_phone,
|
||||
contact_email=staging.contact_email,
|
||||
website=staging.website,
|
||||
# NOTE: location is NOT NULL Geometry in the DB with no default,
|
||||
# so we set a default point (0,0) at SRID 4326
|
||||
location=func.ST_SetSRID(func.ST_MakePoint(0, 0), 4326),
|
||||
)
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
|
||||
# ── 4. Update staging record ─────────────────────────────────────────
|
||||
staging.status = "approved"
|
||||
staging.organization_id = org.id
|
||||
staging.service_profile_id = profile.id
|
||||
staging.published_at = func.now()
|
||||
|
||||
logger.info(
|
||||
f"Promotion complete: staging={staging.id} → "
|
||||
f"org={org.id} ('{org.name}'), profile={profile.id}"
|
||||
)
|
||||
return org, profile
|
||||
|
||||
# ── Main Entry Point ─────────────────────────────────────────────────────
|
||||
|
||||
async def process_staging_score(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
staging: ServiceStaging,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
Compute and apply a validation score for a ServiceStaging entry.
|
||||
|
||||
Logic:
|
||||
- If is_admin: score = 100 (instant approval).
|
||||
- If user_id provided: query UserStats.current_level,
|
||||
compute weight = USER_CONFIRM_BASE + (level * 5).
|
||||
- Otherwise: use BOT_BASE_SCORE from rules.
|
||||
- Log the validation event in provider_validations.
|
||||
- Update staging.validation_level.
|
||||
- If validation_level >= AUTO_APPROVE_THRESHOLD, auto-promote.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
staging: The ServiceStaging record to score.
|
||||
user_id: Optional user who triggered the validation.
|
||||
is_admin: If True, score = 100 (admin override).
|
||||
|
||||
Returns:
|
||||
The new validation_level after applying the score.
|
||||
"""
|
||||
rules = await self.get_rules(db)
|
||||
auto_approve = rules.get(
|
||||
"AUTO_APPROVE_THRESHOLD", DEFAULT_RULES["AUTO_APPROVE_THRESHOLD"]
|
||||
)
|
||||
|
||||
# ── Compute score ────────────────────────────────────────────────────
|
||||
if is_admin:
|
||||
score = 100
|
||||
effective_user_id = 0 # system user placeholder
|
||||
logger.info(f"Admin override: score=100 for staging {staging.id}")
|
||||
elif user_id is not None:
|
||||
weight = await self._get_user_weight(db, user_id, rules)
|
||||
score = weight
|
||||
effective_user_id = user_id
|
||||
logger.info(
|
||||
f"User {user_id} validation: weight={weight} for staging {staging.id}"
|
||||
)
|
||||
else:
|
||||
score = rules.get("BOT_BASE_SCORE", DEFAULT_RULES["BOT_BASE_SCORE"])
|
||||
effective_user_id = 0
|
||||
logger.info(
|
||||
f"Robot base score: {score} for staging {staging.id}"
|
||||
)
|
||||
|
||||
# ── Update validation_level ──────────────────────────────────────────
|
||||
current_level = staging.validation_level or 0
|
||||
new_level = min(current_level + score, 100) # cap at 100
|
||||
staging.validation_level = new_level
|
||||
|
||||
# ── Log validation event ─────────────────────────────────────────────
|
||||
# NOTE: ProviderValidation.provider_id references marketplace.service_providers.id.
|
||||
# For staging-level validations (before promotion), we skip logging to
|
||||
# provider_validations since the staging entry is not a service_provider yet.
|
||||
# After promotion, the _promote_to_provider method can log validations.
|
||||
if effective_user_id > 0 and staging.organization_id is not None:
|
||||
# Only log if the staging has been promoted (has an org)
|
||||
await self._log_validation(
|
||||
db,
|
||||
provider_id=staging.id,
|
||||
voter_user_id=effective_user_id,
|
||||
validation_type="approve" if score >= 0 else "reject",
|
||||
weight=score,
|
||||
metadata={
|
||||
"staging_id": staging.id,
|
||||
"score": score,
|
||||
"validation_level_before": current_level,
|
||||
"validation_level_after": new_level,
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"Staging {staging.id} validation_level: {current_level} → {new_level} "
|
||||
f"(score={score})"
|
||||
)
|
||||
|
||||
# ── Auto-promote if threshold reached ────────────────────────────────
|
||||
if new_level >= auto_approve and staging.status != "approved":
|
||||
logger.info(
|
||||
f"Auto-approve threshold reached ({new_level} >= {auto_approve}) "
|
||||
f"for staging {staging.id}"
|
||||
)
|
||||
await self._promote_to_provider(db, staging)
|
||||
|
||||
return new_level
|
||||
199
backend/app/tests/test_validation_engine.py
Normal file
199
backend/app/tests/test_validation_engine.py
Normal file
@@ -0,0 +1,199 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/tests/test_validation_engine.py
|
||||
"""
|
||||
Test script for ValidationEngine.
|
||||
|
||||
Tests:
|
||||
1. get_rules() — fetches dynamic rules from DB
|
||||
2. process_staging_score() with admin override
|
||||
3. process_staging_score() with user weight
|
||||
4. process_staging_score() with robot base score
|
||||
5. Auto-promotion when threshold reached
|
||||
|
||||
Usage:
|
||||
docker exec -i sf_api python -m app.tests.test_validation_engine
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import (
|
||||
ServiceStaging,
|
||||
ValidationRule,
|
||||
UserStats,
|
||||
)
|
||||
from app.models.identity.social import ProviderValidation
|
||||
from app.services.validation_engine import ValidationEngine
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
|
||||
logger = logging.getLogger("TestValidationEngine")
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
|
||||
def assert_eq(actual, expected, label: str):
|
||||
global PASS, FAIL
|
||||
if actual == expected:
|
||||
PASS += 1
|
||||
logger.info(f" ✅ {label}: {actual} == {expected}")
|
||||
else:
|
||||
FAIL += 1
|
||||
logger.error(f" ❌ {label}: {actual} != {expected}")
|
||||
|
||||
|
||||
async def test_get_rules():
|
||||
"""Test that get_rules() fetches all 4 base rules from the DB."""
|
||||
logger.info("\n📋 Test: get_rules()")
|
||||
engine = ValidationEngine()
|
||||
async with AsyncSessionLocal() as db:
|
||||
rules = await engine.get_rules(db)
|
||||
assert_eq(rules.get("BOT_BASE_SCORE"), 20, "BOT_BASE_SCORE")
|
||||
assert_eq(rules.get("FIRST_ENTRY_SCORE"), 40, "FIRST_ENTRY_SCORE")
|
||||
assert_eq(rules.get("USER_CONFIRM_BASE"), 20, "USER_CONFIRM_BASE")
|
||||
assert_eq(rules.get("AUTO_APPROVE_THRESHOLD"), 80, "AUTO_APPROVE_THRESHOLD")
|
||||
assert_eq(len(rules), 4, "rule count")
|
||||
|
||||
|
||||
async def test_admin_override():
|
||||
"""Test that admin override sets score=100."""
|
||||
logger.info("\n📋 Test: admin override (score=100)")
|
||||
engine = ValidationEngine()
|
||||
async with AsyncSessionLocal() as db:
|
||||
staging = ServiceStaging(
|
||||
name="Test Admin Garage",
|
||||
city="Budapest",
|
||||
fingerprint="test_admin_fp_001",
|
||||
status="pending",
|
||||
validation_level=0,
|
||||
)
|
||||
db.add(staging)
|
||||
await db.flush()
|
||||
|
||||
new_level = await engine.process_staging_score(
|
||||
db, staging, is_admin=True
|
||||
)
|
||||
assert_eq(new_level, 100, "admin validation_level")
|
||||
assert_eq(staging.validation_level, 100, "staging.validation_level")
|
||||
|
||||
# Cleanup
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def test_user_weight():
|
||||
"""Test that user weight is calculated from UserStats.current_level."""
|
||||
logger.info("\n📋 Test: user weight calculation")
|
||||
engine = ValidationEngine()
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Create a staging entry
|
||||
staging = ServiceStaging(
|
||||
name="Test User Garage",
|
||||
city="Debrecen",
|
||||
fingerprint="test_user_fp_001",
|
||||
status="pending",
|
||||
validation_level=0,
|
||||
)
|
||||
db.add(staging)
|
||||
await db.flush()
|
||||
|
||||
# Test with user_id that doesn't exist (should use base weight)
|
||||
new_level = await engine.process_staging_score(
|
||||
db, staging, user_id=999999
|
||||
)
|
||||
# USER_CONFIRM_BASE=20, no UserStats -> base=20
|
||||
assert_eq(new_level, 20, "user weight without stats (base=20)")
|
||||
|
||||
staging.validation_level = 0 # reset
|
||||
|
||||
# Create UserStats for user 1 (if exists)
|
||||
stmt = select(UserStats).where(UserStats.user_id == 1)
|
||||
result = await db.execute(stmt)
|
||||
stats = result.scalar_one_or_none()
|
||||
if stats:
|
||||
level = stats.current_level or 1
|
||||
expected = 20 + (level * 5)
|
||||
new_level = await engine.process_staging_score(
|
||||
db, staging, user_id=1
|
||||
)
|
||||
assert_eq(new_level, expected, f"user weight with level={level}")
|
||||
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def test_robot_base_score():
|
||||
"""Test that robot (no user_id) uses BOT_BASE_SCORE=20."""
|
||||
logger.info("\n📋 Test: robot base score (BOT_BASE_SCORE=20)")
|
||||
engine = ValidationEngine()
|
||||
async with AsyncSessionLocal() as db:
|
||||
staging = ServiceStaging(
|
||||
name="Test Robot Garage",
|
||||
city="Szeged",
|
||||
fingerprint="test_robot_fp_001",
|
||||
status="pending",
|
||||
validation_level=0,
|
||||
)
|
||||
db.add(staging)
|
||||
await db.flush()
|
||||
|
||||
new_level = await engine.process_staging_score(db, staging)
|
||||
assert_eq(new_level, 20, "robot base score")
|
||||
assert_eq(staging.validation_level, 20, "staging.validation_level")
|
||||
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def test_auto_promote():
|
||||
"""Test that staging is auto-promoted when validation_level >= 80."""
|
||||
logger.info("\n📋 Test: auto-promotion at threshold >= 80")
|
||||
engine = ValidationEngine()
|
||||
async with AsyncSessionLocal() as db:
|
||||
staging = ServiceStaging(
|
||||
name="Test Promote Garage",
|
||||
city="Miskolc",
|
||||
postal_code="3500",
|
||||
full_address="3500 Miskolc, Test Street 1",
|
||||
fingerprint="test_promote_fp_001",
|
||||
status="pending",
|
||||
validation_level=40,
|
||||
trust_score=20,
|
||||
)
|
||||
db.add(staging)
|
||||
await db.flush()
|
||||
|
||||
# First pass: admin adds 40 -> 80 (threshold reached)
|
||||
new_level = await engine.process_staging_score(
|
||||
db, staging, is_admin=True
|
||||
)
|
||||
# Admin gives 100, but capped at 100
|
||||
assert_eq(new_level, 100, "auto-promote validation_level")
|
||||
# Status should be 'approved' now
|
||||
assert_eq(staging.status, "approved", "staging.status after promotion")
|
||||
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info("🔍 ValidationEngine Test Suite")
|
||||
logger.info("=" * 60)
|
||||
|
||||
await test_get_rules()
|
||||
await test_admin_override()
|
||||
await test_user_weight()
|
||||
await test_robot_base_score()
|
||||
await test_auto_promote()
|
||||
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info(f"📊 Results: {PASS} passed, {FAIL} failed")
|
||||
logger.info("=" * 60)
|
||||
|
||||
if FAIL > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -6,7 +6,7 @@ import httpx
|
||||
from urllib.parse import quote
|
||||
from sqlalchemy import select, text
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.marketplace.service import ServiceStaging # JAVÍTOTT IMPORT ÚTVONAL!
|
||||
from app.models.marketplace.staged_data import ServiceStaging # JAVÍTVA: helyes import a trust_score oszloppal rendelkező modellhez
|
||||
import re
|
||||
|
||||
# Logolás MB 2.0 szabvány szerint
|
||||
@@ -91,11 +91,10 @@ class OSMScout:
|
||||
if existing is None:
|
||||
full_addr = f"{postcode} {city}, {tags.get('addr:street', '')} {tags.get('addr:housenumber', '')}".strip(" ,")
|
||||
|
||||
# Bővített JSON a nyers adatokhoz, mert a modelled nem tartalmazza a source és trust oszlopokat
|
||||
# P0 FIX: trust_score most már az ORM objektum mezője, nem csak JSON-ben
|
||||
raw_payload = {
|
||||
"osm_tags": tags,
|
||||
"source": "osm_scout_v2",
|
||||
"trust_score": 20
|
||||
}
|
||||
|
||||
new_entry = ServiceStaging(
|
||||
@@ -105,6 +104,7 @@ class OSMScout:
|
||||
full_address=full_addr,
|
||||
fingerprint=f_print,
|
||||
status="pending",
|
||||
trust_score=20, # P0 FIX: BOT_BASE_SCORE az ORM objektumon
|
||||
raw_data=raw_payload
|
||||
)
|
||||
db.add(new_entry)
|
||||
|
||||
Reference in New Issue
Block a user