frontend admin refakctorálás

This commit is contained in:
Roo
2026-07-08 08:03:57 +00:00
parent 07b59032ce
commit 2e0abc62a7
455 changed files with 14428 additions and 21651 deletions

View 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