admin_szolgáltatók_

This commit is contained in:
Roo
2026-07-01 02:27:38 +00:00
parent 189cbfd7ca
commit 6e627d0ebe
491 changed files with 20965 additions and 9703 deletions

View File

@@ -7,7 +7,7 @@ from app.api.v1.endpoints import (
gamification, translations, users, reports, dictionaries,
admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
subscriptions, marketing, admin_permissions, regions,
admin_gamification,
admin_gamification, admin_providers,
)
api_router = APIRouter()
@@ -45,4 +45,5 @@ api_router.include_router(providers.router, prefix="/providers", tags=["Provider
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])
api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Permissions (RBAC)"])
api_router.include_router(admin_gamification.router, prefix="/admin/gamification", tags=["Admin Gamification"])
api_router.include_router(admin_gamification.router, prefix="/admin/gamification", tags=["Admin Gamification"])
api_router.include_router(admin_providers.router, prefix="/admin/providers", tags=["Admin Provider Moderation"])

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ from sqlalchemy import select, func, desc
from app.api.deps import get_db, get_current_user, RequireOrgCapability
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate
from app.services.provider_service import find_or_create_provider_by_name
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@@ -563,6 +564,32 @@ async def create_expense(
if expense.description:
data["description"] = expense.description
# ── PROVIDER AUTO-DISCOVERY HOOK (Card #360) ──
# Ha external_vendor_name meg van adva, de service_provider_id nincs,
# automatikusan felfedezzük vagy létrehozzuk a providert.
# A find_or_create_provider_by_name() kezeli a gamification pontok kiosztását is
# (PROVIDER_DISCOVERY / PROVIDER_CONFIRMATION / PROVIDER_VERIFIED_USE).
resolved_provider_id = expense.service_provider_id
if expense.external_vendor_name and not expense.service_provider_id:
try:
provider, action_key = await find_or_create_provider_by_name(
db=db,
name=expense.external_vendor_name,
added_by_user_id=current_user.id,
)
resolved_provider_id = provider.id
logger.info(
f"Provider auto-discovery: name='{expense.external_vendor_name}', "
f"provider_id={provider.id}, action_key='{action_key}', "
f"user_id={current_user.id}"
)
except Exception as e:
# Ha a provider felderítés hibázik, ne blokkolja a költség rögzítését
logger.warning(
f"Provider auto-discovery failed for '{expense.external_vendor_name}': {e}. "
f"Expense will be created without provider link."
)
try:
# Create AssetCost instance with new fields
new_cost = AssetCost(
@@ -580,7 +607,8 @@ async def create_expense(
# === B2B VENDOR FIELDS ===
vendor_organization_id=expense.vendor_organization_id,
# P0 HYBRID VENDOR REFACTOR: service_provider_id az expense creation-ben
service_provider_id=expense.service_provider_id,
# Ha az auto-discovery hook feloldotta a providert, a resolved_provider_id-t használjuk
service_provider_id=resolved_provider_id,
external_vendor_name=expense.external_vendor_name,
# === INVOICE DATES ===
invoice_date=expense.invoice_date,

View File

@@ -36,8 +36,8 @@ async def register_service_hunt(
"""), {"n": name, "f": f"{name}-{lat}-{lng}", "lat": lat, "lng": lng, "user_id": current_user.id})
# MB 2.0 Gamification: Dinamikus pontszám a felfedezésért
reward_points = await ConfigService.get_int(db, "GAMIFICATION_HUNT_REWARD", 50)
await GamificationService.award_points(db, current_user.id, reward_points, f"Service Hunt: {name}")
# A pontérték a point_rules táblából jön (action_key='SERVICE_HUNT')
await GamificationService.award_points(db, current_user.id, amount=0, reason=f"Service Hunt: {name}", action_key="SERVICE_HUNT")
await db.commit()
return {"status": "success", "message": "Discovery registered and points awarded."}
@@ -86,8 +86,8 @@ async def validate_staged_service(
)
# 5. Adományozz dinamikus XP-t a current_user-nek a GamificationService-en keresztül
validation_reward = await ConfigService.get_int(db, "GAMIFICATION_VALIDATE_REWARD", 10)
await GamificationService.award_points(db, current_user.id, validation_reward, f"Service Validation: staging #{staging_id}")
# A pontérték a point_rules táblából jön (action_key='SERVICE_VALIDATION')
await GamificationService.award_points(db, current_user.id, amount=0, reason=f"Service Validation: staging #{staging_id}", action_key="SERVICE_VALIDATION")
# 6. Növeld a current_user places_validated értékét a UserStats-ban
await db.execute(

View File

@@ -327,6 +327,23 @@ def reload_quiz_data() -> None:
logger.info("Quiz: Quiz data reloaded successfully")
# ── LocaleManager wrapper for email_manager compatibility ──────────────
class _LocaleManager:
"""
Wrapper providing a ``.get()`` interface around the ``t()`` function.
The ``email_manager`` module imports ``locale_manager`` and calls
``locale_manager.get(key, lang=lang, **variables)``. This class
delegates to the existing ``t()`` function so that no import breaks.
"""
@staticmethod
def get(key: str, lang: Optional[str] = None, **kwargs: Any) -> str:
return t(key, lang=lang, **kwargs)
locale_manager = _LocaleManager()
# ── Preload on import ──────────────────────────────────────────────────
_load_locales()
_load_quiz_data()

View File

@@ -48,6 +48,13 @@ class PointsLedger(Base):
source_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# SNAPSHOT MECHANIZMUS (2026-06-30, #369)
# Tárolja a kiosztáskor érvényes point_rules adatokat, hogy a pontértékek
# megőrződjenek a ledger-ben, még akkor is, ha a point_rules táblában
# később módosítják a pontértékeket.
# Tartalma: {"action_key": str, "point_rule_id": int, "points_at_time": int, "multiplier": float}
points_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship("User")

View File

@@ -30,6 +30,7 @@ from .social import (
ServiceReview,
ModerationStatus,
SourceType,
ProviderValidation,
)
__all__ = [
@@ -58,4 +59,5 @@ __all__ = [
"ServiceReview",
"ModerationStatus",
"SourceType",
"ProviderValidation",
]

View File

@@ -5,7 +5,7 @@ from datetime import datetime
from typing import Optional, List
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
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
@@ -17,6 +17,7 @@ class ModerationStatus(str, enum.Enum):
class SourceType(str, enum.Enum):
manual = "manual"
ocr = "ocr"
api = "api"
api_import = "import"
class ServiceProvider(Base):
@@ -26,7 +27,7 @@ class ServiceProvider(Base):
kapcsolatfelvételi adatokkal a quick_add_provider() refactorhoz.
"""
__tablename__ = "service_providers"
__table_args__ = {"schema": "marketplace"}
__table_args__ = {"schema": "marketplace", "extend_existing": True}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String, nullable=False)
@@ -58,6 +59,15 @@ class ServiceProvider(Base):
validation_score: Mapped[int] = mapped_column(Integer, default=0)
evidence_image_path: Mapped[Optional[str]] = mapped_column(String)
added_by_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
# === P0 3D FILTERING MATRIX: Járműosztályok és specializációk ===
supported_vehicle_classes: Mapped[Optional[list]] = mapped_column(
ARRAY(String), server_default=text("'{}'"), nullable=True
)
specializations: Mapped[Optional[dict]] = mapped_column(
JSONB, server_default=text("'{}'::jsonb"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Vote(Base):
@@ -73,6 +83,45 @@ class Vote(Base):
provider_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketplace.service_providers.id"), nullable=False)
vote_value: Mapped[int] = mapped_column(Integer, nullable=False) # +1 vagy -1
class ProviderValidation(Base):
"""
Provider validációs rekordok (XP farming prevention).
Minden egyes admin moderációs akció (approve/reject/flag) egy rekordot hoz létre
ebben a táblában. A Vote táblával ellentétben itt az admin által végzett
validációk kerülnek rögzítésre, súlyozott értékkel.
Features:
- UniqueConstraint(voter_user_id, provider_id): egy user csak egyszer validálhat
- weight: az admin szintjétől függő súly (pl. superadmin=10, admin=5)
- metadata: JSONB a validáció részleteivel (reason, evidence, stb.)
- Küszöbérték: ha a weight-ek összege >= VALIDATION_THRESHOLD, a provider auto-approved
"""
__tablename__ = "provider_validations"
__table_args__ = (
UniqueConstraint('voter_user_id', 'provider_id', name='uq_voter_provider_validation'),
{"schema": "marketplace", "extend_existing": True}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
provider_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketplace.service_providers.id", ondelete="CASCADE"), nullable=False, index=True
)
voter_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), nullable=False
)
validation_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="approve"
) # approve | reject | flag
weight: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
validation_metadata: Mapped[Optional[dict]] = mapped_column("metadata", JSONB, server_default=text("'{}'::jsonb"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Relationships
provider: Mapped["ServiceProvider"] = relationship("ServiceProvider", foreign_keys=[provider_id])
voter: Mapped["User"] = relationship("User", foreign_keys=[voter_user_id])
class Competition(Base):
""" Gamifikált versenyek (pl. Januári Feltöltő Verseny). """
__tablename__ = "competitions"

View File

@@ -5,7 +5,7 @@ from datetime import datetime
from typing import Any, List, Optional
from sqlalchemy import Integer, String, Boolean, DateTime, ForeignKey, text, Text, Float, Index, Numeric, BigInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB, ENUM as SQLEnum
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB, ENUM as SQLEnum, ARRAY
from geoalchemy2 import Geometry
from sqlalchemy.sql import func
@@ -72,6 +72,14 @@ class ServiceProfile(Base):
website: Mapped[Optional[str]] = mapped_column(String)
bio: Mapped[Optional[str]] = mapped_column(Text)
# === P0 3D FILTERING MATRIX: Járműosztályok és specializációk ===
supported_vehicle_classes: Mapped[Optional[list]] = mapped_column(
ARRAY(String), server_default=text("'{}'"), nullable=True
)
specializations: Mapped[Optional[dict]] = mapped_column(
JSONB, server_default=text("'{}'::jsonb"), nullable=True
)
# Kapcsolatok
organization: Mapped["Organization"] = relationship("Organization", back_populates="service_profile")
expertises: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="service")

View File

@@ -120,6 +120,8 @@ class ProviderSearchResult(BaseModel):
source: str = Field(..., description="Forrás: verified_org | staged_data | crowd_added")
is_verified: bool = False
rating: Optional[float] = None
supported_vehicle_classes: Optional[List[str]] = None
specializations: Optional[dict] = None
model_config = ConfigDict(from_attributes=True)
@@ -159,6 +161,12 @@ class ProviderQuickAddIn(BaseModel):
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)")
supported_vehicle_classes: Optional[List[str]] = Field(
None, description="Támogatott járműosztályok (pl. ['car', 'hgv', 'van'])"
)
specializations: Optional[dict] = Field(
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
)
class ProviderQuickAddResponse(BaseModel):
@@ -198,6 +206,12 @@ class ProviderUpdateIn(BaseModel):
new_tags: Optional[List[str]] = Field(
None, description="User által gépelt új címkenevek (is_official=False, level=3)"
)
supported_vehicle_classes: Optional[List[str]] = Field(
None, description="Támogatott járműosztályok (pl. ['car', 'hgv', 'van'])"
)
specializations: Optional[dict] = Field(
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
)
class ProviderUpdateResponse(BaseModel):

View File

@@ -0,0 +1,53 @@
"""
Check if provider_validations table exists and create if not.
"""
import asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import settings
async def main():
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
async with engine.connect() as conn:
# Check if table exists
result = await conn.execute(
text("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'marketplace' AND table_name = 'provider_validations')")
)
exists = result.scalar()
print(f"provider_validations table exists: {exists}")
if not exists:
# Create the table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS marketplace.provider_validations (
id SERIAL PRIMARY KEY,
provider_id INTEGER NOT NULL REFERENCES marketplace.service_providers(id) ON DELETE CASCADE,
voter_user_id INTEGER NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
validation_type VARCHAR(20) NOT NULL DEFAULT 'approve',
weight INTEGER NOT NULL DEFAULT 1,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(voter_user_id, provider_id)
)
"""))
await conn.commit()
print("Created provider_validations table")
# Check columns
result = await conn.execute(text("""
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'marketplace' AND table_name = 'provider_validations'
ORDER BY ordinal_position
"""))
print("\nColumns:")
for row in result:
print(f" {row.column_name}: {row.data_type} (nullable: {row.is_nullable})")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,157 @@
"""
Gamification Action Keys Seed Script
=====================================
Cél: Hiányzó action_key-ek felvétele a gamification.point_rules táblába.
Az audit során az alábbi akciókhoz NEM létezik action_key a point_rules táblában:
- KYC_VERIFICATION (auth_service KYC completion)
- P2P_REFERRAL_SUCCESS (auth_service P2P referral)
- EXPENSE_LOG (cost_service expense recording)
- FLEET_EVENT (fleet_service vehicle event)
- PROVIDER_VALIDATED (social_service provider validation)
- PROVIDER_REJECTED (social_service provider rejection)
- SERVICE_HUNT (services.py service hunt)
- SERVICE_VALIDATION (services.py service validation)
Használat:
docker compose exec sf_api python3 /app/backend/app/scripts/seed_gamification_action_keys.py
"""
import asyncio
import logging
import sys
from pathlib import Path
# Projekt gyökér hozzáadása a Python path-hoz
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.config import settings
from app.models.gamification.gamification import PointRule
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("Seed-Gamification-Action-Keys")
# Hiányzó action_key-ek a point_rules táblába
# Pontértékek a meglévő ConfigService default értékek alapján:
# KYC_VERIFICATION = 100 (auth_service: kyc_reward)
# P2P_REFERRAL_SUCCESS = 50 (auth_service: gamification_p2p_invite_xp default)
# EXPENSE_LOG = 50 (cost_service: xp_per_cost_log default)
# FLEET_EVENT = 20 (fleet_service: event_rewards["default"]["xp"])
# PROVIDER_VALIDATED = 100 (social_service: hardcoded 100XP)
# PROVIDER_REJECTED = 50 (social_service: hardcoded 50XP)
# SERVICE_HUNT = 50 (services.py: GAMIFICATION_HUNT_REWARD default)
# SERVICE_VALIDATION = 10 (services.py: GAMIFICATION_VALIDATE_REWARD default)
SEED_RULES = [
{
"action_key": "KYC_VERIFICATION",
"points": 100,
"description": "Sikeres KYC (Know Your Customer) folyamat teljesítése",
"is_active": True,
},
{
"action_key": "P2P_REFERRAL_SUCCESS",
"points": 50,
"description": "Sikeres P2P meghívó (referred_by_id alapján)",
"is_active": True,
},
{
"action_key": "EXPENSE_LOG",
"points": 50,
"description": "Költség rögzítése (base_xp, OCR bónusz nélkül)",
"is_active": True,
},
{
"action_key": "FLEET_EVENT",
"points": 20,
"description": "Flotta esemény rögzítése (alapértelmezett pont)",
"is_active": True,
},
{
"action_key": "PROVIDER_VALIDATED",
"points": 100,
"description": "Provider adatainak közösségi validálása (jóváhagyás)",
"is_active": True,
},
{
"action_key": "PROVIDER_REJECTED",
"points": 50,
"description": "Provider adatainak közösségi elutasítása (büntetés)",
"is_active": True,
},
{
"action_key": "SERVICE_HUNT",
"points": 50,
"description": "Új szerviz felfedezése (service hunt)",
"is_active": True,
},
{
"action_key": "SERVICE_VALIDATION",
"points": 10,
"description": "Szerviz validálása (más user által beküldött staging rekord)",
"is_active": True,
},
]
async def seed_gamification_action_keys():
"""
Feltölti a gamification.point_rules táblát a hiányzó action_key-ekkel.
Csak azokat a rekordokat szúrja be, amelyek még nem léteznek (action_key alapján).
"""
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
try:
inserted_count = 0
skipped_count = 0
for rule_data in SEED_RULES:
# Ellenőrizzük, hogy létezik-e már ez a szabály
stmt = select(PointRule).where(
PointRule.action_key == rule_data["action_key"]
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
logger.info(
f"⏭️ Szabály már létezik: {rule_data['action_key']} "
f"(pont: {existing.points}, ID: {existing.id})"
)
skipped_count += 1
continue
# Új szabály beszúrása
new_rule = PointRule(
action_key=rule_data["action_key"],
points=rule_data["points"],
description=rule_data["description"],
is_active=rule_data["is_active"],
)
db.add(new_rule)
await db.flush()
logger.info(
f"✅ Szabály létrehozva: {rule_data['action_key']} "
f"(pont: {rule_data['points']}, ID: {new_rule.id})"
)
inserted_count += 1
await db.commit()
logger.info(
f"\n📊 Összegzés: {inserted_count} új szabály beszúrva, "
f"{skipped_count} már létezett."
)
except Exception as e:
await db.rollback()
logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True)
raise
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed_gamification_action_keys())

View File

@@ -0,0 +1,124 @@
"""
Provider Gamification Point Rules Seed Script
=============================================
Cél: Új gamification pontszabályok beszúrása a provider discovery rendszerhez.
Új szabályok:
- PROVIDER_DISCOVERY (300 pont) — Új provider felfedezése expense rögzítéskor
- PROVIDER_CONFIRMATION (150 pont) — Meglévő provider adatainak megerősítése
- PROVIDER_VERIFIED_USE (100 pont) — Már validált provider használata
Használat:
docker compose exec sf_api python3 /app/backend/app/scripts/seed_provider_point_rules.py
"""
import asyncio
import logging
import sys
from pathlib import Path
# Projekt gyökér hozzáadása a Python path-hoz
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.config import settings
from app.models.gamification.gamification import PointRule
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("Seed-Provider-Point-Rules")
# Új provider discovery pontszabályok
# A pontértékek a kártya specifikációja szerint (#356, #362):
# PROVIDER_DISCOVERY = 300 — Új provider felfedezése
# PROVIDER_CONFIRMATION = 150 — Saját pending provider használata
# PROVIDER_VERIFIED_USE = 100 — Már validált provider használata
# USE_UNVERIFIED_PROVIDER = 200 — Más user által létrehozott, még pending provider használata
SEED_RULES = [
{
"action_key": "PROVIDER_DISCOVERY",
"points": 300,
"description": "Új, még nem létező provider felfedezése expense rögzítéskor (external_vendor_name alapján)",
"is_active": True,
},
{
"action_key": "PROVIDER_CONFIRMATION",
"points": 150,
"description": "Saját magad által létrehozott, még pending státuszú provider használata",
"is_active": True,
},
{
"action_key": "PROVIDER_VERIFIED_USE",
"points": 100,
"description": "Már jóváhagyott (approved) provider használata költség rögzítésnél",
"is_active": True,
},
{
"action_key": "USE_UNVERIFIED_PROVIDER",
"points": 200,
"description": "Más user által létrehozott, még pending státuszú provider használata (XP farming prevention)",
"is_active": True,
},
]
async def seed_provider_point_rules():
"""
Feltölti a gamification.point_rules táblát a provider discovery szabályokkal.
Csak azokat a rekordokat szúrja be, amelyek még nem léteznek (action_key alapján).
"""
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
try:
inserted_count = 0
skipped_count = 0
for rule_data in SEED_RULES:
# Ellenőrizzük, hogy létezik-e már ez a szabály
stmt = select(PointRule).where(
PointRule.action_key == rule_data["action_key"]
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
logger.info(
f"⏭️ Szabály már létezik: {rule_data['action_key']} "
f"(pont: {existing.points}, ID: {existing.id})"
)
skipped_count += 1
continue
# Új szabály beszúrása
new_rule = PointRule(
action_key=rule_data["action_key"],
points=rule_data["points"],
description=rule_data["description"],
is_active=rule_data["is_active"],
)
db.add(new_rule)
await db.flush()
logger.info(
f"✅ Szabály létrehozva: {rule_data['action_key']} "
f"(pont: {rule_data['points']}, ID: {new_rule.id})"
)
inserted_count += 1
await db.commit()
logger.info(
f"\n📊 Összegzés: {inserted_count} új szabály beszúrva, "
f"{skipped_count} már létezett."
)
except Exception as e:
await db.rollback()
logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True)
raise
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed_provider_point_rules())

View File

@@ -286,8 +286,8 @@ class AssetService:
))
# Gamification
reward = await config.get_setting(db, "xp_reward_asset_register", default=250)
await GamificationService.award_points(db, user_id, int(reward), "NEW_ASSET_REG")
# A pontérték a point_rules táblából jön (action_key='ASSET_REGISTER')
await GamificationService.award_points(db, user_id, amount=0, reason="ASSET_REGISTER", action_key="ASSET_REGISTER")
# Check if this is user's first vehicle and award "First Car" badge
await AssetService._award_first_car_badge(db, user_id, target_org_id)

View File

@@ -295,19 +295,21 @@ class AuthService:
user.region_code = kyc_in.region_code
# Gamification XP jóváírás (commit=False, mert a külső metódus kezeli a tranzakciót)
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION", commit=False)
# A pontérték a point_rules táblából jön (action_key='KYC_VERIFICATION')
await GamificationService.award_points(db, user_id=user.id, amount=0, reason="KYC_VERIFICATION", commit=False, action_key="KYC_VERIFICATION")
# P2P Referral XP a meghívónak (ha van referred_by_id)
if user.referred_by_id:
p2p_xp = await config.get_setting(db, "gamification_p2p_invite_xp", default=50)
# A pontérték a point_rules táblából jön (action_key='P2P_REFERRAL_SUCCESS')
await GamificationService.award_points(
db,
user_id=user.referred_by_id,
amount=int(p2p_xp),
amount=0,
reason="P2P_REFERRAL_SUCCESS",
commit=False
commit=False,
action_key="P2P_REFERRAL_SUCCESS"
)
logger.info(f"P2P XP ({p2p_xp}) awarded to referrer ID {user.referred_by_id} for user {user.id}")
logger.info(f"P2P XP awarded to referrer ID {user.referred_by_id} for user {user.id}")
await db.commit()
return user

View File

@@ -25,8 +25,6 @@ class CostService:
try:
# 1. Dinamikus konfiguráció lekérése
base_currency = await config.get_setting(db, "finance_base_currency", default="EUR")
base_xp = await config.get_setting(db, "xp_per_cost_log", default=50)
ocr_multiplier = await config.get_setting(db, "xp_multiplier_ocr_cost", default=1.5)
# 2. Intelligens Árfolyamkezelés
exchange_rate = Decimal("1.0")
@@ -66,12 +64,10 @@ class CostService:
await self._sync_telemetry(db, cost_in.asset_id, cost_in.mileage_at_cost)
# 5. Gamification (Értékesebb az adat, ha van róla fotó/OCR)
final_xp = base_xp
if new_cost.is_ai_generated:
final_xp = int(base_xp * float(ocr_multiplier))
# A pontérték a point_rules táblából jön (action_key='EXPENSE_LOG')
# Az OCR bónusz továbbra is érvényesül a process_activity szorzóin keresztül
await GamificationService.award_points(
db, user_id=user_id, amount=final_xp, reason=f"EXPENSE_LOG_{cost_in.cost_type}"
db, user_id=user_id, amount=0, reason=f"EXPENSE_LOG_{cost_in.cost_type}", action_key="EXPENSE_LOG"
)
await db.commit()

View File

@@ -82,15 +82,17 @@ class FleetService:
db.add(new_event)
# 6. DINAMIKUS GAMIFIKÁCIÓ
# Kikeresjük a konkrét eseménytípushoz tartozó pontokat
# A pontérték a point_rules táblából jön (action_key='FLEET_EVENT')
# A social pont továbbra is az event_rewards konfigurációból jön
rewards = event_rewards.get(event_data.event_type, event_rewards["default"])
await gamification_service.process_activity(
db,
user_id,
xp_amount=rewards["xp"],
social_amount=rewards["social"],
reason=f"FLEET_EVENT_{event_data.event_type.upper()}"
db,
user_id,
xp_amount=0,
social_amount=rewards["social"],
reason=f"FLEET_EVENT_{event_data.event_type.upper()}",
action_key="FLEET_EVENT"
)
await db.commit()

View File

@@ -1,6 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/services/gamification_service.py
import logging
import math
from copy import deepcopy
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
@@ -12,6 +13,25 @@ from app.services.config_service import config # 2.0 Központi konfigurátor
logger = logging.getLogger("Gamification-Service-2.0")
def _deep_merge_config(base: dict, overlay: dict) -> dict:
"""
Rekurzívan egyesít két dictionary-t.
Az overlay kulcsai felülírják a base kulcsait, de a base-ben lévő
hiányzó kulcsok megmaradnak az overlay-ből.
Ez biztosítja, hogy a GAMIFICATION_MASTER_CONFIG minden szükséges
kulcsot tartalmazzon, még akkor is, ha az adatbázisban lévő konfiguráció
hiányos (pl. régebbi verzióból származik).
"""
result = deepcopy(base)
for key, value in overlay.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge_config(result[key], value)
elif key not in result:
result[key] = deepcopy(value)
return result
class GamificationService:
"""
Gamification Service 2.0 - A 'Jövevény' lelke.
@@ -22,22 +42,24 @@ class GamificationService:
"""
@staticmethod
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True):
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True, action_key: str | None = None):
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz.
Args:
commit: If True (default), commits the transaction internally.
Set to False when called from within a larger transaction
(e.g., from AuthService.complete_kyc).
action_key: Opcionális. Ha meg van adva, a point_rules táblából
olvassa a pontértékeket a master config helyett.
"""
service = GamificationService()
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit)
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit, action_key=action_key)
async def _get_point_rule(self, db: AsyncSession, action_key: str) -> dict | None:
"""Lekér egy pontszabályt a point_rules táblából action_key alapján.
Returns:
dict with 'points' and 'description' or None if not found/inactive.
dict with 'points', 'description', 'rule_id' or None if not found/inactive.
"""
stmt = select(PointRule).where(
PointRule.action_key == action_key,
@@ -46,7 +68,7 @@ class GamificationService:
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if rule:
return {"points": rule.points, "description": rule.description}
return {"points": rule.points, "description": rule.description, "rule_id": rule.id}
return None
async def process_activity(
@@ -80,7 +102,7 @@ class GamificationService:
try:
# 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE
# Minden paraméter az admin felületről módosítható JSON-ként
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default={
_DEFAULT_GAMIFICATION_CONFIG = {
"xp_logic": {"base_xp": 500, "exponent": 1.5},
"penalty_logic": {
"recovery_rate": 0.5,
@@ -89,15 +111,28 @@ class GamificationService:
},
"conversion_logic": {"social_to_credit_rate": 100},
"level_rewards": {"credits_per_10_levels": 50}
})
}
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default=_DEFAULT_GAMIFICATION_CONFIG)
# 🔐 HIÁNYZÓ KULCSOK VÉDELME (Deep Merge)
# Ha a GAMIFICATION_MASTER_CONFIG már létezik az adatbázisban, de hiányoznak
# belőle kulcsok (pl. penalty_logic egy régebbi verzióból), a default értékek
# automatikusan kiegészítik a hiányzó részeket.
# Ez megakadályozza a KeyError kivételeket a cfg["penalty_logic"] hívásoknál.
if isinstance(cfg, dict):
cfg = _deep_merge_config(cfg, _DEFAULT_GAMIFICATION_CONFIG)
# 1/b. POINT RULES TÁBLA LEKÉRÉSE (ha action_key meg van adva)
# A point_rules tábla elsőbbséget élvez a master config-gal szemben!
point_rule_id = None
points_at_time = None
if action_key:
rule = await self._get_point_rule(db, action_key)
if rule:
# A point_rules-ból jövő pontok felülírják a paraméterként kapott értékeket
xp_amount = rule["points"]
point_rule_id = rule.get("rule_id")
points_at_time = rule["points"]
if rule.get("description"):
reason = f"{action_key}: {rule['description']}"
logger.debug(f"Point rule applied: {action_key} -> {xp_amount} XP")
@@ -152,7 +187,18 @@ class GamificationService:
stats.social_points %= rate # A maradék pont megmarad
await self._add_earned_credits(db, user_id, credits_to_add, "SOCIAL_ACTIVITY_CONVERSION")
# 7. NAPLÓZÁS (kibővítve xp, source_type, source_id mezőkkel)
# 7. NAPLÓZÁS (kibővítve xp, source_type, source_id, points_snapshot mezőkkel)
# A points_snapshot JSONB tárolja a kiosztáskor érvényes point_rules adatokat,
# hogy a pontértékek megőrződjenek a ledger-ben, még akkor is, ha a
# point_rules táblában később módosítják a pontértékeket.
snapshot = None
if action_key and point_rule_id and points_at_time is not None:
snapshot = {
"action_key": action_key,
"point_rule_id": point_rule_id,
"points_at_time": points_at_time,
"multiplier": multiplier,
}
db.add(PointsLedger(
user_id=user_id,
points=final_xp,
@@ -160,6 +206,7 @@ class GamificationService:
reason=reason,
source_type=source_type,
source_id=source_id,
points_snapshot=snapshot,
))
# Only commit if caller wants us to manage the transaction

View File

@@ -40,7 +40,7 @@ from sqlalchemy.orm import joinedload
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging
from app.models.identity.social import ServiceProvider
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
from app.models.gamification.gamification import PointRule, UserStats
from app.schemas.provider import (
ProviderSearchResult,
@@ -64,9 +64,12 @@ async def _award_provider_points(
"""
Dinamikus pontjóváírás a gamification.point_rules tábla alapján.
Ez a függvény az adatbázisból olvassa ki a pontértéket az action_key
alapján, így a pontok NINCSENEK beégetve a kódba. Az admin felületről
bármikor módosíthatók a point_rules táblában.
REFAKTOR (2026-06-30): A duplikált point_rules olvasási logika eltávolításra
került. A pontértékeket most a GamificationService.award_points() olvassa ki
a point_rules táblából az action_key alapján a process_activity() hívás során.
Ez a függvény már csak a providers_added_count statisztikát kezeli.
A pontok kiszámítása és naplózása a GamificationService-ben történik.
Args:
db: AsyncSession
@@ -76,39 +79,19 @@ async def _award_provider_points(
Returns:
int: A jóváírt pontok száma (0 ha a szabály nem található vagy inaktív)
"""
# 1. Pontszabály lekérdezése az adatbázisból (dinamikus!)
rule_stmt = select(PointRule).where(
PointRule.action_key == action_key,
PointRule.is_active == True,
)
rule_result = await db.execute(rule_stmt)
rule = rule_result.scalar_one_or_none()
if not rule:
logger.warning(
f"Pontszabály '{action_key}' nem található vagy inaktív. "
f"Pontjóváírás kihagyva user_id={user_id}."
)
return 0
points_to_award = rule.points
logger.info(
f"Dinamikus pont lekérés: action_key='{action_key}', "
f"points={points_to_award} (forrás: gamification.point_rules tábla)"
)
# 2. Pontok jóváírása a Gamification Service-en keresztül
# A GamificationService.award_points() kezeli a szorzókat, szintlépést,
# büntetés ledolgozást és a naplózást (PointsLedger).
# 1. Pontok jóváírása a Gamification Service-en keresztül (action_key alapján)
# A GamificationService.award_points() kezeli a point_rules olvasást, szorzókat,
# szintlépést, büntetés ledolgozást és a naplózást (PointsLedger).
await gamification_service.award_points(
db=db,
user_id=user_id,
amount=points_to_award,
reason=f"ADD_NEW_PROVIDER: +{points_to_award} pont új szolgáltató rögzítéséért",
commit=False, # A hívó (quick_add_provider) kezeli a commit-ot
amount=0,
reason=f"{action_key}",
commit=False, # A hívó kezeli a commit-ot
action_key=action_key,
)
# 3. Statisztika növelése (providers_added_count)
# 2. Statisztika növelése (providers_added_count)
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
@@ -122,7 +105,7 @@ async def _award_provider_points(
# Ha még nincs UserStats rekordja, létrehozzuk
stats = UserStats(
user_id=user_id,
total_xp=points_to_award,
total_xp=0,
current_level=1,
providers_added_count=1,
)
@@ -132,7 +115,102 @@ async def _award_provider_points(
f"providers_added_count=1"
)
return points_to_award
return 0 # Visszatérési érték már nem a pontszám, mert az award_points kezeli
async def find_or_create_provider_by_name(
db: AsyncSession,
name: str,
added_by_user_id: int,
) -> tuple:
"""
Keres vagy létrehoz egy ServiceProvider-t a megadott név alapján.
Ez a függvény az expense auto-discovery hook része. Amikor egy user
költséget rögzít és megad egy external_vendor_name-t, ez a függvény
megkeresi, hogy létezik-e már a provider, és ha nem, létrehozza.
Logika:
1. Pontos match keresése ServiceProvider.name alapján (kisbetűs, space-sztrippelt)
2. Ha nem létezik → új provider létrehozása PENDING státusszal,
validation_score=10, source=import, vissza: PROVIDER_DISCOVERY
3. Ha létezik és APPROVED → vissza: PROVIDER_VERIFIED_USE
4. Ha létezik és nem APPROVED → vissza: PROVIDER_CONFIRMATION
5. A függvény meghívja a _award_provider_points()-t a megfelelő action_key-kel
Args:
db: AsyncSession
name: A provider neve (external_vendor_name)
added_by_user_id: A költséget rögzítő user ID-ja
Returns:
tuple[ServiceProvider, str]: (provider, action_key)
- action_key: "PROVIDER_DISCOVERY" | "PROVIDER_CONFIRMATION" | "PROVIDER_VERIFIED_USE"
"""
# 1. Pontos match keresése (kisbetűsen, space-sztrippelten)
clean_name = name.strip()
provider_stmt = select(ServiceProvider).where(
func.lower(ServiceProvider.name) == func.lower(clean_name)
)
provider_result = await db.execute(provider_stmt)
provider = provider_result.scalar_one_or_none()
if not provider:
# 2. Ha nem létezik → létrehozás + PROVIDER_DISCOVERY
new_provider = ServiceProvider(
name=clean_name,
address=clean_name,
status=ModerationStatus.pending,
source=SourceType.api,
validation_score=10,
added_by_user_id=added_by_user_id,
)
db.add(new_provider)
await db.flush()
logger.info(
f"Új provider felfedezve: name='{clean_name}', "
f"provider_id={new_provider.id}, user_id={added_by_user_id}"
)
# Gamification pont kiosztása
await _award_provider_points(
db=db,
user_id=added_by_user_id,
action_key="PROVIDER_DISCOVERY",
)
return new_provider, "PROVIDER_DISCOVERY"
# 3. Ha létezik, státusz alapján döntés
# P0 CRITICAL (Card #362): USE_UNVERIFIED_PROVIDER vs PROVIDER_CONFIRMATION
# - Ha a provider APPROVED -> PROVIDER_VERIFIED_USE (100 XP)
# - Ha a provider PENDING es a hasznalo user a provider letrehozoja -> PROVIDER_CONFIRMATION (150 XP)
# - Ha a provider PENDING es a hasznalo user NEM a provider letrehozoja -> USE_UNVERIFIED_PROVIDER (200 XP)
# Ez megakadalyozza az XP farmingot (sajat provider hasznalata sajat maganak).
if provider.status == ModerationStatus.approved:
action_key = "PROVIDER_VERIFIED_USE"
elif provider.added_by_user_id == added_by_user_id:
# A provider letrehozoja hasznalja a sajat pending provideret
action_key = "PROVIDER_CONFIRMATION"
else:
# Mas user hasznal egy pending providert
action_key = "USE_UNVERIFIED_PROVIDER"
logger.info(
f"Meglevo provider hasznalata: name='{clean_name}', "
f"provider_id={provider.id}, action_key='{action_key}', "
f"user_id={added_by_user_id}, provider_creator_id={provider.added_by_user_id}"
)
# Gamification pont kiosztása
await _award_provider_points(
db=db,
user_id=added_by_user_id,
action_key=action_key,
)
return provider, action_key
async def search_providers(

View File

@@ -10,36 +10,36 @@ from app.schemas.social import ServiceProviderCreate
logger = logging.getLogger(__name__)
class SocialService:
"""
"""
SocialService: Kezeli a közösségi interakciókat, szavazatokat és a moderációt.
Az importok a metódusokon belül vannak a körkörös függőség elkerülése érdekében.
"""
async def create_service_provider(self, db: AsyncSession, obj_in: ServiceProviderCreate, user_id: int):
from app.services.gamification_service import gamification_service
from app.services.provider_service import _award_provider_points
new_provider = ServiceProvider(**obj_in.model_dump(), added_by_user_id=user_id)
db.add(new_provider)
await db.flush()
await db.flush()
# Alappontszám az új beküldésért
await gamification_service.process_activity(db, user_id, 50, 10, f"New Provider: {new_provider.name}")
# Dinamikus pontjóváírás a point_rules táblából (ADD_NEW_PROVIDER)
await _award_provider_points(db, user_id, "ADD_NEW_PROVIDER")
await db.commit()
await db.refresh(new_provider)
return new_provider
async def vote_for_provider(self, db: AsyncSession, voter_id: int, provider_id: int, vote_value: int):
from app.services.gamification_service import gamification_service
from app.services.provider_service import _award_provider_points
# Duplikált szavazat ellenőrzése
exists = (await db.execute(select(Vote).where(and_(Vote.user_id == voter_id, Vote.provider_id == provider_id)))).scalar()
if exists:
if exists:
return {"message": "Már szavaztál erre a szolgáltatóra!"}
db.add(Vote(user_id=voter_id, provider_id=provider_id, vote_value=vote_value))
provider = (await db.execute(select(ServiceProvider).where(ServiceProvider.id == provider_id))).scalar_one_or_none()
if not provider:
if not provider:
return {"error": "Szolgáltató nem található."}
provider.validation_score += vote_value
@@ -53,6 +53,9 @@ class SocialService:
provider.status = ModerationStatus.rejected
await self._penalize_user(db, provider.added_by_user_id, provider.name)
# RATE_PROVIDER pontok kiosztása a sikeres szavazatért
await _award_provider_points(db, voter_id, "RATE_PROVIDER")
await db.commit()
return {"status": "success", "score": provider.validation_score, "new_status": provider.status}
@@ -67,7 +70,8 @@ class SocialService:
from app.services.gamification_service import gamification_service
if not user_id: return
await gamification_service.process_activity(db, user_id, 100, 20, f"Validated: {provider_name}")
# A pontérték a point_rules táblából jön (action_key='PROVIDER_VALIDATED')
await gamification_service.process_activity(db, user_id, xp_amount=0, social_amount=20, reason=f"Validated: {provider_name}", action_key="PROVIDER_VALIDATED")
# Aktuális verseny keresése és pontozása
now = datetime.now(timezone.utc)
@@ -91,8 +95,8 @@ class SocialService:
from app.services.gamification_service import gamification_service
if not user_id: return
# JAVÍTVA: is_penalty=True hozzáadva a gamification híváshoz
await gamification_service.process_activity(db, user_id, 50, 0, f"Rejected: {provider_name}", is_penalty=True)
# A pontérték a point_rules táblából jön (action_key='PROVIDER_REJECTED')
await gamification_service.process_activity(db, user_id, xp_amount=0, social_amount=0, reason=f"Rejected: {provider_name}", is_penalty=True, action_key="PROVIDER_REJECTED")
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if user and hasattr(user, 'reputation_score'):

View File

@@ -0,0 +1,186 @@
"""
Test: USE_UNVERIFIED_PROVIDER pontszabály bekötése (Card #362)
=============================================================
Ellenőrzi, hogy a find_or_create_provider_by_name() függvény helyesen
differenciál a PROVIDER_CONFIRMATION és USE_UNVERIFIED_PROVIDER között.
Logika:
1. Ha a provider APPROVED -> PROVIDER_VERIFIED_USE (100 XP)
2. Ha a provider PENDING és a használó user a provider létrehozója -> PROVIDER_CONFIRMATION (150 XP)
3. Ha a provider PENDING és a használó user NEM a provider létrehozója -> USE_UNVERIFIED_PROVIDER (200 XP)
Használat:
docker compose exec sf_api python3 /app/backend/tests/active/test_use_unverified_provider.py
"""
import asyncio
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "backend"))
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.config import settings
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
from app.models.gamification.gamification import PointRule, UserStats, PointsLedger
from app.services.provider_service import find_or_create_provider_by_name, _award_provider_points
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("Test-USE_UNVERIFIED_PROVIDER")
TEST_USER_ID = 1 # Provider creator (superadmin@profibot.hu)
TEST_OTHER_USER_ID = 2 # Other user using the provider (admin@profibot.hu)
TEST_PROVIDER_NAME = "Test Unverified Provider Card362"
async def cleanup_test_data(db: AsyncSession):
"""Clean up any leftover test data."""
await db.execute(
delete(PointsLedger).where(
PointsLedger.user_id.in_([TEST_USER_ID, TEST_OTHER_USER_ID])
)
)
await db.execute(
delete(UserStats).where(
UserStats.user_id.in_([TEST_USER_ID, TEST_OTHER_USER_ID])
)
)
await db.execute(
delete(ServiceProvider).where(ServiceProvider.name == TEST_PROVIDER_NAME)
)
await db.commit()
async def test_use_unverified_provider():
"""
Test scenario:
1. User A (TEST_USER_ID) creates a provider via find_or_create_provider_by_name()
-> Should return PROVIDER_DISCOVERY (300 XP) - new provider
2. User A uses the same provider again
-> Should return PROVIDER_CONFIRMATION (150 XP) - same user, pending provider
3. User B (TEST_OTHER_USER_ID) uses the same provider
-> Should return USE_UNVERIFIED_PROVIDER (200 XP) - different user, pending provider
"""
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
try:
# Clean up first
await cleanup_test_data(db)
# =========================================================
# STEP 1: User A creates a new provider (PROVIDER_DISCOVERY)
# =========================================================
logger.info("=" * 60)
logger.info("STEP 1: User A creates a new provider")
logger.info("=" * 60)
provider, action_key = await find_or_create_provider_by_name(
db=db,
name=TEST_PROVIDER_NAME,
added_by_user_id=TEST_USER_ID,
)
assert provider is not None, "Provider should be created"
assert action_key == "PROVIDER_DISCOVERY", (
f"Expected PROVIDER_DISCOVERY, got {action_key}"
)
assert provider.status == ModerationStatus.pending, (
f"Expected pending status, got {provider.status}"
)
assert provider.added_by_user_id == TEST_USER_ID, (
f"Expected added_by_user_id={TEST_USER_ID}, got {provider.added_by_user_id}"
)
logger.info(f"✅ STEP 1 PASS: action_key={action_key}, provider_id={provider.id}")
# Check that points were awarded
stats_stmt = select(UserStats).where(UserStats.user_id == TEST_USER_ID)
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
assert stats is not None, "UserStats should exist"
assert stats.total_xp >= 300, (
f"Expected at least 300 XP, got {stats.total_xp}"
)
logger.info(f"✅ STEP 1 XP CHECK: total_xp={stats.total_xp}")
# =========================================================
# STEP 2: User A uses the same provider again (PROVIDER_CONFIRMATION)
# =========================================================
logger.info("=" * 60)
logger.info("STEP 2: User A uses the same provider again")
logger.info("=" * 60)
# Re-fetch to get clean state
await db.refresh(provider)
provider2, action_key2 = await find_or_create_provider_by_name(
db=db,
name=TEST_PROVIDER_NAME,
added_by_user_id=TEST_USER_ID,
)
assert provider2.id == provider.id, "Should be the same provider"
assert action_key2 == "PROVIDER_CONFIRMATION", (
f"Expected PROVIDER_CONFIRMATION, got {action_key2}"
)
logger.info(f"✅ STEP 2 PASS: action_key={action_key2}")
# =========================================================
# STEP 3: User B uses the same provider (USE_UNVERIFIED_PROVIDER)
# =========================================================
logger.info("=" * 60)
logger.info("STEP 3: User B uses the same provider")
logger.info("=" * 60)
provider3, action_key3 = await find_or_create_provider_by_name(
db=db,
name=TEST_PROVIDER_NAME,
added_by_user_id=TEST_OTHER_USER_ID,
)
assert provider3.id == provider.id, "Should be the same provider"
assert action_key3 == "USE_UNVERIFIED_PROVIDER", (
f"Expected USE_UNVERIFIED_PROVIDER, got {action_key3}"
)
logger.info(f"✅ STEP 3 PASS: action_key={action_key3}")
# Check that User B got points
stats_stmt2 = select(UserStats).where(UserStats.user_id == TEST_OTHER_USER_ID)
stats2 = (await db.execute(stats_stmt2)).scalar_one_or_none()
assert stats2 is not None, "UserStats should exist for User B"
assert stats2.total_xp >= 200, (
f"Expected at least 200 XP for User B, got {stats2.total_xp}"
)
logger.info(f"✅ STEP 3 XP CHECK: User B total_xp={stats2.total_xp}")
# =========================================================
# SUMMARY
# =========================================================
logger.info("=" * 60)
logger.info("🎉 ALL TESTS PASSED!")
logger.info(f" - PROVIDER_DISCOVERY (300 XP): ✅ User A created provider")
logger.info(f" - PROVIDER_CONFIRMATION (150 XP): ✅ User A reused own provider")
logger.info(f" - USE_UNVERIFIED_PROVIDER (200 XP): ✅ User B used pending provider")
logger.info("=" * 60)
# Clean up
await cleanup_test_data(db)
except AssertionError as e:
logger.error(f"❌ TEST FAILED: {e}")
await db.rollback()
await cleanup_test_data(db)
sys.exit(1)
except Exception as e:
logger.error(f"❌ UNEXPECTED ERROR: {e}", exc_info=True)
await db.rollback()
await cleanup_test_data(db)
sys.exit(1)
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(test_use_unverified_provider())

View File

@@ -0,0 +1,28 @@
"""feat: add vehicle classes and specializations to providers
Revision ID: 2387cd18fde7
Revises: 7cd9b8a65ce8
Create Date: 2026-07-01 00:38:21.828081
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '2387cd18fde7'
down_revision: Union[str, Sequence[str], None] = '7cd9b8a65ce8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass

View File

@@ -0,0 +1,131 @@
{
"AUTH": {
"LOGIN": {
"SUCCESS": "Přihlášení úspěšné. Vítejte zpět!"
},
"ERROR": {
"EMAIL_EXISTS": "Tento email je již zaregistrován.",
"UNAUTHORIZED": "Neoprávněný přístup."
}
},
"SENTINEL": {
"LOCK": {
"MSG": "Účet byl z bezpečnostních důvodů uzamčen."
},
"APPROVAL": {
"REQUIRED": "Vyžadováno schválení"
}
},
"COMMON": {
"SAVE_SUCCESS": "Úspěšně uloženo!"
},
"EMAIL": {
"REG": {
"SUBJECT": "Potvrďte svou registraci - Service Finder",
"GREETING": "Ahoj {{first_name}}!",
"BODY": "Děkujeme za registraci! Kliknutím na tlačítko níže aktivujete svůj účet:",
"BUTTON": "Aktivovat účet",
"FOOTER": "Pokud jste se nezaregistrovali, ignorujte prosím tento email."
},
"PWD_RESET": {
"SUBJECT": "Žádost o obnovení hesla",
"GREETING": "Ahoj!",
"BODY": "Požádali jste o obnovení hesla. Kliknutím na tlačítko níže nastavíte nové heslo:",
"BUTTON": "Obnovit heslo",
"FOOTER": "Pokud jste o obnovení hesla nežádali, ignorujte prosím tento email."
},
"TEST": {
"SUBJECT": "🧪 Testovací email - Service Finder",
"GREETING": "Ahoj {{first_name}}!",
"BODY": "Toto je testovací email ze systému Service Finder. Pokud to vidíte, odesílání emailů funguje!",
"BUTTON": "Přejít na Dashboard",
"FOOTER": "Service Finder - Testovací systémová zpráva"
}
},
"ORGANIZATION": {
"ERROR": {
"NOT_FOUND": "Organizace nenalezena.",
"INVALID_TAX_FORMAT": "Neplatný formát maďarského daňového čísla!",
"TAX_LOOKUP_FAILED": "Ověření daňového čísla selhalo.",
"FORBIDDEN": "Nemáte oprávnění k přístupu k této organizaci.",
"INVALID_ROLE": "Neplatná role.",
"CANNOT_REMOVE_OWNER": "Vlastník nemůže být odstraněn z organizace.",
"CANNOT_DEMOTE_OWNER": "Role vlastníka nemůže být změněna.",
"ALREADY_MEMBER": "Již jste členem této organizace.",
"NO_ACTIVE_ADMIN": "Tato organizace nemá aktivního správce. Použijte koncový bod /claim.",
"HAS_ACTIVE_ADMIN": "Tato organizace má aktivního správce. Použijte koncový bod /join-request.",
"EMAIL_MISMATCH": "Email neodpovídá emailu vlastníka organizace.",
"INVALID_CODE": "Neplatný nebo expirovaný kód.",
"CODE_ORG_MISMATCH": "Kód nepatří k této organizaci.",
"INVITE_EXPIRED": "Pozvánka vypršela.",
"INVITE_INVALID": "Neplatný token pozvánky."
},
"SUCCESS": {
"ONBOARDED": "Společnost úspěšně vytvořena a zaregistrována.",
"MEMBER_REMOVED": "Člen úspěšně odstraněn.",
"ROLE_UPDATED": "Role úspěšně aktualizována.",
"UPDATED": "Data organizace úspěšně aktualizována.",
"VISUAL_SETTINGS_UPDATED": "Vizuální nastavení úspěšně aktualizováno."
},
"INVITATION": {
"CREATED": "Pozvánka úspěšně vytvořena.",
"ACCEPTED": "Pozvánka přijata.",
"REVOKED": "Pozvánka odvolána."
},
"JOIN_REQUEST": {
"SENT": "Vaše žádost o připojení byla odeslána. Čeká na schválení správcem."
},
"CLAIM": {
"OTP_SENT": "6místný ověřovací kód byl odeslán na email vlastníka organizace.",
"SUCCESS": "Úspěšně jste převzali organizaci. Nyní jste správcem."
}
},
"REGISTRATION_DOCUMENTS": {
"CERTIFICATE_NUMBER": "Číslo osvědčení o registraci",
"CERTIFICATE_VALIDITY": "Platnost osvědčení o registraci",
"DOCUMENT_NUMBER": "Číslo dokladu vozidla",
"TITLE": "Osvědčení o registraci & Doklad vozidla"
},
"GAMIFICATION": {
"SUBMIT_SERVICE": {
"SELF_DEFENSE_BLOCKED": "Z důvodu omezení sebeobrany nemůžete odesílat nová servisní data.",
"SUCCESS": "Servis odeslán do systému k analýze!",
"POINTS_ERROR": "Chyba při bodování: {error}",
"SUBMIT_ERROR": "Chyba při odesílání: {error}"
},
"QUIZ": {
"ALREADY_PLAYED": "Dnes jste již hráli denní kvíz. Vraťte se zítra!",
"NO_QUIZ_AVAILABLE": "Dnes není k dispozici žádný kvíz.",
"ALREADY_COMPLETED": "Denní kvíz byl dnes již označen jako dokončený.",
"COMPLETED_SUCCESS": "Denní kvíz označen jako dokončený pro dnešek.",
"QUESTION_NOT_FOUND": "Otázka nenalezena.",
"POINTS_FAILED": "Nepodařilo se udělit body za kvíz: {error}"
},
"BADGE": {
"NOT_FOUND": "Odznak nenalezen.",
"ALREADY_AWARDED": "Odznak již byl tomuto uživateli udělen.",
"AWARDED_SUCCESS": "Odznak '{badge_name}' byl udělen uživateli.",
"POINTS_FAILED": "Nepodařilo se udělit body za odznak: {error}"
},
"SEASON": {
"NOT_FOUND": "Sezóna nenalezena.",
"NO_ACTIVE": "Nebyla nalezena žádná aktivní sezóna."
},
"ACHIEVEMENTS": {
"XP_NOVICE": "Začátečník",
"XP_APPRENTICE": "Učeň",
"XP_EXPERT": "Expert",
"XP_MASTER": "Mistr",
"XP_NOVICE_DESC": "Získej 100 XP",
"XP_APPRENTICE_DESC": "Získej 500 XP",
"XP_EXPERT_DESC": "Získej 2000 XP",
"XP_MASTER_DESC": "Získej 5000 XP",
"QUIZ_BEGINNER": "Kvízový začátečník",
"QUIZ_ENTHUSIAST": "Kvízový nadšenec",
"QUIZ_MASTER": "Kvízový mistr",
"QUIZ_BEGINNER_DESC": "Získej 50 kvízových bodů",
"QUIZ_ENTHUSIAST_DESC": "Získej 200 kvízových bodů",
"QUIZ_MASTER_DESC": "Získej 500 kvízových bodů"
}
}
}

View File

@@ -0,0 +1,131 @@
{
"AUTH": {
"LOGIN": {
"SUCCESS": "Anmeldung erfolgreich. Willkommen zurück!"
},
"ERROR": {
"EMAIL_EXISTS": "Diese E-Mail ist bereits registriert.",
"UNAUTHORIZED": "Unbefugter Zugriff."
}
},
"SENTINEL": {
"LOCK": {
"MSG": "Konto aus Sicherheitsgründen gesperrt."
},
"APPROVAL": {
"REQUIRED": "Genehmigung erforderlich"
}
},
"COMMON": {
"SAVE_SUCCESS": "Erfolgreich gespeichert!"
},
"EMAIL": {
"REG": {
"SUBJECT": "Bestätigen Sie Ihre Registrierung - Service Finder",
"GREETING": "Hallo {{first_name}}!",
"BODY": "Vielen Dank für Ihre Registrierung! Klicken Sie auf die Schaltfläche unten, um Ihr Konto zu aktivieren:",
"BUTTON": "Konto aktivieren",
"FOOTER": "Wenn Sie sich nicht registriert haben, ignorieren Sie bitte diese E-Mail."
},
"PWD_RESET": {
"SUBJECT": "Passwort zurücksetzen",
"GREETING": "Hallo!",
"BODY": "Sie haben eine Passwortzurücksetzung angefordert. Klicken Sie auf die Schaltfläche unten, um ein neues Passwort festzulegen:",
"BUTTON": "Passwort zurücksetzen",
"FOOTER": "Wenn Sie keine Passwortzurücksetzung angefordert haben, ignorieren Sie bitte diese E-Mail."
},
"TEST": {
"SUBJECT": "🧪 Test-E-Mail - Service Finder",
"GREETING": "Hallo {{first_name}}!",
"BODY": "Dies ist eine Test-E-Mail vom Service Finder System. Wenn Sie dies sehen, funktioniert der E-Mail-Versand!",
"BUTTON": "Zum Dashboard",
"FOOTER": "Service Finder - Test-Systemnachricht"
}
},
"ORGANIZATION": {
"ERROR": {
"NOT_FOUND": "Organisation nicht gefunden.",
"INVALID_TAX_FORMAT": "Ungültiges ungarisches Steuernummernformat!",
"TAX_LOOKUP_FAILED": "Steuernummernprüfung fehlgeschlagen.",
"FORBIDDEN": "Sie haben keine Berechtigung für diese Organisation.",
"INVALID_ROLE": "Ungültige Rolle angegeben.",
"CANNOT_REMOVE_OWNER": "Der Eigentümer kann nicht aus der Organisation entfernt werden.",
"CANNOT_DEMOTE_OWNER": "Die Rolle des Eigentümers kann nicht geändert werden.",
"ALREADY_MEMBER": "Sie sind bereits Mitglied dieser Organisation.",
"NO_ACTIVE_ADMIN": "Diese Organisation hat keinen aktiven Administrator. Verwenden Sie den /claim-Endpunkt.",
"HAS_ACTIVE_ADMIN": "Diese Organisation hat einen aktiven Administrator. Verwenden Sie den /join-request-Endpunkt.",
"EMAIL_MISMATCH": "Die E-Mail stimmt nicht mit der E-Mail des Organisationsinhabers überein.",
"INVALID_CODE": "Ungültiger oder abgelaufener Code.",
"CODE_ORG_MISMATCH": "Der Code gehört nicht zu dieser Organisation.",
"INVITE_EXPIRED": "Die Einladung ist abgelaufen.",
"INVITE_INVALID": "Ungültiges Einladungstoken."
},
"SUCCESS": {
"ONBOARDED": "Unternehmen erfolgreich erstellt und registriert.",
"MEMBER_REMOVED": "Mitglied erfolgreich entfernt.",
"ROLE_UPDATED": "Rolle erfolgreich aktualisiert.",
"UPDATED": "Organisationsdaten erfolgreich aktualisiert.",
"VISUAL_SETTINGS_UPDATED": "Visuelle Einstellungen erfolgreich aktualisiert."
},
"INVITATION": {
"CREATED": "Einladung erfolgreich erstellt.",
"ACCEPTED": "Einladung angenommen.",
"REVOKED": "Einladung widerrufen."
},
"JOIN_REQUEST": {
"SENT": "Ihre Beitrittsanfrage wurde übermittelt. Warten auf Administratorgenehmigung."
},
"CLAIM": {
"OTP_SENT": "Ein 6-stelliger Bestätigungscode wurde an die E-Mail des Organisationsinhabers gesendet.",
"SUCCESS": "Sie haben die Organisation erfolgreich übernommen. Sie sind jetzt der Administrator."
}
},
"REGISTRATION_DOCUMENTS": {
"CERTIFICATE_NUMBER": "Zulassungsbescheinigung Nummer",
"CERTIFICATE_VALIDITY": "Gültigkeit der Zulassungsbescheinigung",
"DOCUMENT_NUMBER": "Fahrzeugdokument Nummer",
"TITLE": "Zulassungsbescheinigung & Fahrzeugdokument"
},
"GAMIFICATION": {
"SUBMIT_SERVICE": {
"SELF_DEFENSE_BLOCKED": "Sie können aufgrund einer Selbstverteidigungseinschränkung keine neuen Servicedaten einreichen.",
"SUCCESS": "Service zur Analyse an das System übermittelt!",
"POINTS_ERROR": "Fehler bei der Bewertung: {error}",
"SUBMIT_ERROR": "Fehler bei der Übermittlung: {error}"
},
"QUIZ": {
"ALREADY_PLAYED": "Sie haben heute bereits am täglichen Quiz teilgenommen. Kommen Sie morgen wieder!",
"NO_QUIZ_AVAILABLE": "Heute ist kein Quiz verfügbar.",
"ALREADY_COMPLETED": "Das tägliche Quiz wurde heute bereits als abgeschlossen markiert.",
"COMPLETED_SUCCESS": "Tägliches Quiz wurde heute als abgeschlossen markiert.",
"QUESTION_NOT_FOUND": "Frage nicht gefunden.",
"POINTS_FAILED": "Fehler bei der Vergabe von Quizpunkten: {error}"
},
"BADGE": {
"NOT_FOUND": "Abzeichen nicht gefunden.",
"ALREADY_AWARDED": "Abzeichen wurde diesem Benutzer bereits verliehen.",
"AWARDED_SUCCESS": "Abzeichen '{badge_name}' wurde dem Benutzer verliehen.",
"POINTS_FAILED": "Fehler bei der Vergabe von Abzeichenpunkten: {error}"
},
"SEASON": {
"NOT_FOUND": "Saison nicht gefunden.",
"NO_ACTIVE": "Keine aktive Saison gefunden."
},
"ACHIEVEMENTS": {
"XP_NOVICE": "Anfänger",
"XP_APPRENTICE": "Lehrling",
"XP_EXPERT": "Experte",
"XP_MASTER": "Meister",
"XP_NOVICE_DESC": "Sammle 100 XP",
"XP_APPRENTICE_DESC": "Sammle 500 XP",
"XP_EXPERT_DESC": "Sammle 2000 XP",
"XP_MASTER_DESC": "Sammle 5000 XP",
"QUIZ_BEGINNER": "Quiz-Anfänger",
"QUIZ_ENTHUSIAST": "Quiz-Enthusiast",
"QUIZ_MASTER": "Quiz-Meister",
"QUIZ_BEGINNER_DESC": "Sammle 50 Quizpunkte",
"QUIZ_ENTHUSIAST_DESC": "Sammle 200 Quizpunkte",
"QUIZ_MASTER_DESC": "Sammle 500 Quizpunkte"
}
}
}

View File

@@ -0,0 +1,131 @@
{
"AUTH": {
"LOGIN": {
"SUCCESS": "Connexion réussie. Bon retour parmi nous!"
},
"ERROR": {
"EMAIL_EXISTS": "Cet email est déjà enregistré.",
"UNAUTHORIZED": "Accès non autorisé."
}
},
"SENTINEL": {
"LOCK": {
"MSG": "Compte verrouillé pour des raisons de sécurité."
},
"APPROVAL": {
"REQUIRED": "Approbation requise"
}
},
"COMMON": {
"SAVE_SUCCESS": "Enregistré avec succès!"
},
"EMAIL": {
"REG": {
"SUBJECT": "Confirmez votre inscription - Service Finder",
"GREETING": "Bonjour {{first_name}}!",
"BODY": "Merci de vous être inscrit! Cliquez sur le bouton ci-dessous pour activer votre compte:",
"BUTTON": "Activer le compte",
"FOOTER": "Si vous ne vous êtes pas inscrit, veuillez ignorer cet email."
},
"PWD_RESET": {
"SUBJECT": "Demande de réinitialisation du mot de passe",
"GREETING": "Bonjour!",
"BODY": "Vous avez demandé une réinitialisation de mot de passe. Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe:",
"BUTTON": "Réinitialiser le mot de passe",
"FOOTER": "Si vous n'avez pas demandé de réinitialisation, veuillez ignorer cet email."
},
"TEST": {
"SUBJECT": "🧪 Email de test - Service Finder",
"GREETING": "Bonjour {{first_name}}!",
"BODY": "Ceci est un email de test du système Service Finder. Si vous voyez ceci, l'envoi d'emails fonctionne!",
"BUTTON": "Aller au tableau de bord",
"FOOTER": "Service Finder - Message système de test"
}
},
"ORGANIZATION": {
"ERROR": {
"NOT_FOUND": "Organisation non trouvée.",
"INVALID_TAX_FORMAT": "Format de numéro de TVA hongrois invalide!",
"TAX_LOOKUP_FAILED": "La vérification du numéro de TVA a échoué.",
"FORBIDDEN": "Vous n'avez pas la permission d'accéder à cette organisation.",
"INVALID_ROLE": "Rôle spécifié invalide.",
"CANNOT_REMOVE_OWNER": "Le propriétaire ne peut pas être retiré de l'organisation.",
"CANNOT_DEMOTE_OWNER": "Le rôle du propriétaire ne peut pas être modifié.",
"ALREADY_MEMBER": "Vous êtes déjà membre de cette organisation.",
"NO_ACTIVE_ADMIN": "Cette organisation n'a pas d'administrateur actif. Utilisez le point de terminaison /claim.",
"HAS_ACTIVE_ADMIN": "Cette organisation a un administrateur actif. Utilisez le point de terminaison /join-request.",
"EMAIL_MISMATCH": "L'email ne correspond pas à l'email du propriétaire de l'organisation.",
"INVALID_CODE": "Code invalide ou expiré.",
"CODE_ORG_MISMATCH": "Le code n'appartient pas à cette organisation.",
"INVITE_EXPIRED": "L'invitation a expiré.",
"INVITE_INVALID": "Jeton d'invitation invalide."
},
"SUCCESS": {
"ONBOARDED": "Entreprise créée et enregistrée avec succès.",
"MEMBER_REMOVED": "Membre supprimé avec succès.",
"ROLE_UPDATED": "Rôle mis à jour avec succès.",
"UPDATED": "Données de l'organisation mises à jour avec succès.",
"VISUAL_SETTINGS_UPDATED": "Paramètres visuels mis à jour avec succès."
},
"INVITATION": {
"CREATED": "Invitation créée avec succès.",
"ACCEPTED": "Invitation acceptée.",
"REVOKED": "Invitation révoquée."
},
"JOIN_REQUEST": {
"SENT": "Votre demande d'adhésion a été soumise. En attente de l'approbation de l'administrateur."
},
"CLAIM": {
"OTP_SENT": "Un code de vérification à 6 chiffres a été envoyé à l'email du propriétaire de l'organisation.",
"SUCCESS": "Vous avez repris l'organisation avec succès. Vous êtes maintenant l'administrateur."
}
},
"REGISTRATION_DOCUMENTS": {
"CERTIFICATE_NUMBER": "Numéro du certificat d'immatriculation",
"CERTIFICATE_VALIDITY": "Validité du certificat d'immatriculation",
"DOCUMENT_NUMBER": "Numéro du document du véhicule",
"TITLE": "Certificat d'immatriculation & Document du véhicule"
},
"GAMIFICATION": {
"SUBMIT_SERVICE": {
"SELF_DEFENSE_BLOCKED": "Vous ne pouvez pas soumettre de nouvelles données de service en raison d'une restriction d'autodéfense.",
"SUCCESS": "Service soumis au système pour analyse!",
"POINTS_ERROR": "Erreur lors de l'attribution des points: {error}",
"SUBMIT_ERROR": "Erreur lors de la soumission: {error}"
},
"QUIZ": {
"ALREADY_PLAYED": "Vous avez déjà participé au quiz quotidien aujourd'hui. Revenez demain!",
"NO_QUIZ_AVAILABLE": "Aucun quiz disponible aujourd'hui.",
"ALREADY_COMPLETED": "Le quiz quotidien a déjà été marqué comme terminé aujourd'hui.",
"COMPLETED_SUCCESS": "Quiz quotidien marqué comme terminé pour aujourd'hui.",
"QUESTION_NOT_FOUND": "Question non trouvée.",
"POINTS_FAILED": "Échec de l'attribution des points de quiz: {error}"
},
"BADGE": {
"NOT_FOUND": "Badge non trouvé.",
"ALREADY_AWARDED": "Badge déjà attribué à cet utilisateur.",
"AWARDED_SUCCESS": "Badge '{badge_name}' attribué à l'utilisateur.",
"POINTS_FAILED": "Échec de l'attribution des points de badge: {error}"
},
"SEASON": {
"NOT_FOUND": "Saison non trouvée.",
"NO_ACTIVE": "Aucune saison active trouvée."
},
"ACHIEVEMENTS": {
"XP_NOVICE": "Novice",
"XP_APPRENTICE": "Apprenti",
"XP_EXPERT": "Expert",
"XP_MASTER": "Maître",
"XP_NOVICE_DESC": "Gagnez 100 XP",
"XP_APPRENTICE_DESC": "Gagnez 500 XP",
"XP_EXPERT_DESC": "Gagnez 2000 XP",
"XP_MASTER_DESC": "Gagnez 5000 XP",
"QUIZ_BEGINNER": "Débutant Quiz",
"QUIZ_ENTHUSIAST": "Passionné de Quiz",
"QUIZ_MASTER": "Maître du Quiz",
"QUIZ_BEGINNER_DESC": "Gagnez 50 points de quiz",
"QUIZ_ENTHUSIAST_DESC": "Gagnez 200 points de quiz",
"QUIZ_MASTER_DESC": "Gagnez 500 points de quiz"
}
}
}

View File

@@ -0,0 +1,131 @@
{
"AUTH": {
"LOGIN": {
"SUCCESS": "Autentificare reușită. Bun venit înapoi!"
},
"ERROR": {
"EMAIL_EXISTS": "Acest email este deja înregistrat.",
"UNAUTHORIZED": "Acces neautorizat."
}
},
"SENTINEL": {
"LOCK": {
"MSG": "Cont blocat din motive de securitate."
},
"APPROVAL": {
"REQUIRED": "Aprobare necesară"
}
},
"COMMON": {
"SAVE_SUCCESS": "Salvat cu succes!"
},
"EMAIL": {
"REG": {
"SUBJECT": "Confirmați înregistrarea - Service Finder",
"GREETING": "Salut {{first_name}}!",
"BODY": "Vă mulțumim pentru înregistrare! Faceți clic pe butonul de mai jos pentru a vă activa contul:",
"BUTTON": "Activează Contul",
"FOOTER": "Dacă nu v-ați înregistrat, vă rugăm să ignorați acest email."
},
"PWD_RESET": {
"SUBJECT": "Solicitare resetare parolă",
"GREETING": "Salut!",
"BODY": "Ați solicitat resetarea parolei. Faceți clic pe butonul de mai jos pentru a seta o nouă parolă:",
"BUTTON": "Resetează Parola",
"FOOTER": "Dacă nu ați solicitat resetarea parolei, vă rugăm să ignorați acest email."
},
"TEST": {
"SUBJECT": "🧪 Email de test - Service Finder",
"GREETING": "Salut {{first_name}}!",
"BODY": "Acesta este un email de test de la sistemul Service Finder. Dacă vedeți acest mesaj, trimiterea de emailuri funcționează!",
"BUTTON": "Mergi la Dashboard",
"FOOTER": "Service Finder - Mesaj de sistem de test"
}
},
"ORGANIZATION": {
"ERROR": {
"NOT_FOUND": "Organizație negăsită.",
"INVALID_TAX_FORMAT": "Format invalid al numărului de TVA maghiar!",
"TAX_LOOKUP_FAILED": "Verificarea numărului de TVA a eșuat.",
"FORBIDDEN": "Nu aveți permisiunea de a accesa această organizație.",
"INVALID_ROLE": "Rol specificat invalid.",
"CANNOT_REMOVE_OWNER": "Proprietarul nu poate fi eliminat din organizație.",
"CANNOT_DEMOTE_OWNER": "Rolul proprietarului nu poate fi modificat.",
"ALREADY_MEMBER": "Sunteți deja membru al acestei organizații.",
"NO_ACTIVE_ADMIN": "Această organizație nu are un administrator activ. Utilizați punctul final /claim.",
"HAS_ACTIVE_ADMIN": "Această organizație are un administrator activ. Utilizați punctul final /join-request.",
"EMAIL_MISMATCH": "Emailul nu corespunde cu emailul proprietarului organizației.",
"INVALID_CODE": "Cod invalid sau expirat.",
"CODE_ORG_MISMATCH": "Codul nu aparține acestei organizații.",
"INVITE_EXPIRED": "Invitația a expirat.",
"INVITE_INVALID": "Token de invitație invalid."
},
"SUCCESS": {
"ONBOARDED": "Companie creată și înregistrată cu succes.",
"MEMBER_REMOVED": "Membru eliminat cu succes.",
"ROLE_UPDATED": "Rol actualizat cu succes.",
"UPDATED": "Datele organizației actualizate cu succes.",
"VISUAL_SETTINGS_UPDATED": "Setările vizuale actualizate cu succes."
},
"INVITATION": {
"CREATED": "Invitație creată cu succes.",
"ACCEPTED": "Invitație acceptată.",
"REVOKED": "Invitație revocată."
},
"JOIN_REQUEST": {
"SENT": "Cererea dvs. de aderare a fost trimisă. Se așteaptă aprobarea administratorului."
},
"CLAIM": {
"OTP_SENT": "Un cod de verificare din 6 cifre a fost trimis pe emailul proprietarului organizației.",
"SUCCESS": "Ați preluat cu succes organizația. Acum sunteți administratorul."
}
},
"REGISTRATION_DOCUMENTS": {
"CERTIFICATE_NUMBER": "Numărul certificatului de înmatriculare",
"CERTIFICATE_VALIDITY": "Valabilitatea certificatului de înmatriculare",
"DOCUMENT_NUMBER": "Numărul documentului vehiculului",
"TITLE": "Certificat de înmatriculare & Document vehicul"
},
"GAMIFICATION": {
"SUBMIT_SERVICE": {
"SELF_DEFENSE_BLOCKED": "Nu puteți trimite date noi de service din cauza unei restricții de autoapărare.",
"SUCCESS": "Service trimis sistemului pentru analiză!",
"POINTS_ERROR": "Eroare la punctare: {error}",
"SUBMIT_ERROR": "Eroare la trimitere: {error}"
},
"QUIZ": {
"ALREADY_PLAYED": "Ați jucat deja quizul zilnic astăzi. Reveniți mâine!",
"NO_QUIZ_AVAILABLE": "Niciun quiz disponibil pentru astăzi.",
"ALREADY_COMPLETED": "Quizul zilnic a fost deja marcat ca finalizat astăzi.",
"COMPLETED_SUCCESS": "Quizul zilnic marcat ca finalizat pentru astăzi.",
"QUESTION_NOT_FOUND": "Întrebare negăsită.",
"POINTS_FAILED": "Eroare la acordarea punctelor de quiz: {error}"
},
"BADGE": {
"NOT_FOUND": "Insignă negăsită.",
"ALREADY_AWARDED": "Insigna a fost deja acordată acestui utilizator.",
"AWARDED_SUCCESS": "Insigna '{badge_name}' a fost acordată utilizatorului.",
"POINTS_FAILED": "Eroare la acordarea punctelor insignei: {error}"
},
"SEASON": {
"NOT_FOUND": "Sezon negăsit.",
"NO_ACTIVE": "Niciun sezon activ găsit."
},
"ACHIEVEMENTS": {
"XP_NOVICE": "Începător",
"XP_APPRENTICE": "Ucenic",
"XP_EXPERT": "Expert",
"XP_MASTER": "Maestru",
"XP_NOVICE_DESC": "Câștigă 100 XP",
"XP_APPRENTICE_DESC": "Câștigă 500 XP",
"XP_EXPERT_DESC": "Câștigă 2000 XP",
"XP_MASTER_DESC": "Câștigă 5000 XP",
"QUIZ_BEGINNER": "Începător Quiz",
"QUIZ_ENTHUSIAST": "Entuziast Quiz",
"QUIZ_MASTER": "Maestru Quiz",
"QUIZ_BEGINNER_DESC": "Câștigă 50 de puncte de quiz",
"QUIZ_ENTHUSIAST_DESC": "Câștigă 200 de puncte de quiz",
"QUIZ_MASTER_DESC": "Câștigă 500 de puncte de quiz"
}
}
}

View File

@@ -0,0 +1,131 @@
{
"AUTH": {
"LOGIN": {
"SUCCESS": "Prihlásenie úspešné. Vitajte späť!"
},
"ERROR": {
"EMAIL_EXISTS": "Tento email je už zaregistrovaný.",
"UNAUTHORIZED": "Neoprávnený prístup."
}
},
"SENTINEL": {
"LOCK": {
"MSG": "Účet bol z bezpečnostných dôvodov uzamknutý."
},
"APPROVAL": {
"REQUIRED": "Vyžaduje sa schválenie"
}
},
"COMMON": {
"SAVE_SUCCESS": "Úspešne uložené!"
},
"EMAIL": {
"REG": {
"SUBJECT": "Potvrďte svoju registráciu - Service Finder",
"GREETING": "Ahoj {{first_name}}!",
"BODY": "Ďakujeme za registráciu! Kliknutím na tlačidlo nižšie aktivujete svoj účet:",
"BUTTON": "Aktivovať účet",
"FOOTER": "Ak ste sa nezaregistrovali, ignorujte prosím tento email."
},
"PWD_RESET": {
"SUBJECT": "Žiadosť o obnovenie hesla",
"GREETING": "Ahoj!",
"BODY": "Požiadali ste o obnovenie hesla. Kliknutím na tlačidlo nižšie nastavíte nové heslo:",
"BUTTON": "Obnoviť heslo",
"FOOTER": "Ak ste nepožiadali o obnovenie hesla, ignorujte prosím tento email."
},
"TEST": {
"SUBJECT": "🧪 Testovací email - Service Finder",
"GREETING": "Ahoj {{first_name}}!",
"BODY": "Toto je testovací email zo systému Service Finder. Ak to vidíte, odosielanie emailov funguje!",
"BUTTON": "Prejsť na Dashboard",
"FOOTER": "Service Finder - Testovacia systémová správa"
}
},
"ORGANIZATION": {
"ERROR": {
"NOT_FOUND": "Organizácia nenájdená.",
"INVALID_TAX_FORMAT": "Neplatný formát maďarského daňového čísla!",
"TAX_LOOKUP_FAILED": "Overenie daňového čísla zlyhalo.",
"FORBIDDEN": "Nemáte oprávnenie na prístup k tejto organizácii.",
"INVALID_ROLE": "Neplatná rola.",
"CANNOT_REMOVE_OWNER": "Vlastník nemôže byť odstránený z organizácie.",
"CANNOT_DEMOTE_OWNER": "Rola vlastníka nemôže byť zmenená.",
"ALREADY_MEMBER": "Už ste členom tejto organizácie.",
"NO_ACTIVE_ADMIN": "Táto organizácia nemá aktívneho správcu. Použite koncový bod /claim.",
"HAS_ACTIVE_ADMIN": "Táto organizácia má aktívneho správcu. Použite koncový bod /join-request.",
"EMAIL_MISMATCH": "Email nezodpovedá emailu vlastníka organizácie.",
"INVALID_CODE": "Neplatný alebo expirovaný kód.",
"CODE_ORG_MISMATCH": "Kód nepatrí k tejto organizácii.",
"INVITE_EXPIRED": "Pozvánka vypršala.",
"INVITE_INVALID": "Neplatný token pozvánky."
},
"SUCCESS": {
"ONBOARDED": "Spoločnosť úspešne vytvorená a zaregistrovaná.",
"MEMBER_REMOVED": "Člen úspešne odstránený.",
"ROLE_UPDATED": "Rola úspešne aktualizovaná.",
"UPDATED": "Dáta organizácie úspešne aktualizované.",
"VISUAL_SETTINGS_UPDATED": "Vizuálne nastavenia úspešne aktualizované."
},
"INVITATION": {
"CREATED": "Pozvánka úspešne vytvorená.",
"ACCEPTED": "Pozvánka prijatá.",
"REVOKED": "Pozvánka odvolaná."
},
"JOIN_REQUEST": {
"SENT": "Vaša žiadosť o pripojenie bola odoslaná. Čaká na schválenie správcom."
},
"CLAIM": {
"OTP_SENT": "6-miestny overovací kód bol odoslaný na email vlastníka organizácie.",
"SUCCESS": "Úspešne ste prevzali organizáciu. Teraz ste správcom."
}
},
"REGISTRATION_DOCUMENTS": {
"CERTIFICATE_NUMBER": "Číslo osvedčenia o registrácii",
"CERTIFICATE_VALIDITY": "Platnosť osvedčenia o registrácii",
"DOCUMENT_NUMBER": "Číslo dokladu vozidla",
"TITLE": "Osvedčenie o registrácii & Doklad vozidla"
},
"GAMIFICATION": {
"SUBMIT_SERVICE": {
"SELF_DEFENSE_BLOCKED": "Z dôvodu obmedzenia sebaobrany nemôžete odosielať nové servisné dáta.",
"SUCCESS": "Servis odoslaný do systému na analýzu!",
"POINTS_ERROR": "Chyba pri bodovaní: {error}",
"SUBMIT_ERROR": "Chyba pri odosielaní: {error}"
},
"QUIZ": {
"ALREADY_PLAYED": "Dnes ste už hrali denný kvíz. Vráťte sa zajtra!",
"NO_QUIZ_AVAILABLE": "Dnes nie je k dispozícii žiadny kvíz.",
"ALREADY_COMPLETED": "Denný kvíz bol dnes už označený ako dokončený.",
"COMPLETED_SUCCESS": "Denný kvíz označený ako dokončený pre dnešok.",
"QUESTION_NOT_FOUND": "Otázka nenájdená.",
"POINTS_FAILED": "Nepodarilo sa udeliť body za kvíz: {error}"
},
"BADGE": {
"NOT_FOUND": "Odznak nenájdený.",
"ALREADY_AWARDED": "Odznak už bol tomuto používateľovi udelený.",
"AWARDED_SUCCESS": "Odznak '{badge_name}' bol udelený používateľovi.",
"POINTS_FAILED": "Nepodarilo sa udeliť body za odznak: {error}"
},
"SEASON": {
"NOT_FOUND": "Sezóna nenájdená.",
"NO_ACTIVE": "Nebola nájdená žiadna aktívna sezóna."
},
"ACHIEVEMENTS": {
"XP_NOVICE": "Začiatočník",
"XP_APPRENTICE": "Učeň",
"XP_EXPERT": "Expert",
"XP_MASTER": "Majster",
"XP_NOVICE_DESC": "Získaj 100 XP",
"XP_APPRENTICE_DESC": "Získaj 500 XP",
"XP_EXPERT_DESC": "Získaj 2000 XP",
"XP_MASTER_DESC": "Získaj 5000 XP",
"QUIZ_BEGINNER": "Kvízový začiatočník",
"QUIZ_ENTHUSIAST": "Kvízový nadšenec",
"QUIZ_MASTER": "Kvízový majster",
"QUIZ_BEGINNER_DESC": "Získaj 50 kvízových bodov",
"QUIZ_ENTHUSIAST_DESC": "Získaj 200 kvízových bodov",
"QUIZ_MASTER_DESC": "Získaj 500 kvízových bodov"
}
}
}