admin frontend elkezdése, járművek tisztázása, frontend fejleszts
This commit is contained in:
@@ -228,12 +228,34 @@ class AssetService:
|
||||
# --- MATCHER INTEGRÁCIÓ (HA NINCS CATALOG_ID DE VANNAK ADATOK) ---
|
||||
if not new_asset.catalog_id and new_asset.brand and new_asset.model:
|
||||
from app.services.asset_matcher_service import AssetMatcherService
|
||||
from app.models.vehicle.asset import AssetCatalog
|
||||
print(f"DEBUG: Matcher args: make={new_asset.brand}, model={new_asset.model}, year={new_asset.year_of_manufacture}")
|
||||
matched_result = await AssetMatcherService.find_best_match(db, new_asset)
|
||||
if matched_result:
|
||||
matched_def, conf = matched_result
|
||||
if matched_def:
|
||||
new_asset.catalog_id = matched_def.id
|
||||
# FIX: Asset.catalog_id FK references vehicle.vehicle_catalog.id (AssetCatalog),
|
||||
# NOT vehicle.vehicle_model_definitions.id (VehicleModelDefinition).
|
||||
# Look up or create the AssetCatalog entry for this definition.
|
||||
catalog_stmt = select(AssetCatalog).where(
|
||||
AssetCatalog.master_definition_id == matched_def.id
|
||||
).limit(1)
|
||||
catalog_entry = (await db.execute(catalog_stmt)).scalar_one_or_none()
|
||||
if not catalog_entry:
|
||||
# Create a new AssetCatalog entry from the matched definition
|
||||
catalog_entry = AssetCatalog(
|
||||
master_definition_id=matched_def.id,
|
||||
make=matched_def.make,
|
||||
model=matched_def.marketing_name,
|
||||
year_from=matched_def.year_from,
|
||||
year_to=matched_def.year_to,
|
||||
fuel_type=matched_def.fuel_type,
|
||||
power_kw=matched_def.power_kw if matched_def.power_kw and matched_def.power_kw > 0 else None,
|
||||
engine_capacity=matched_def.engine_capacity if matched_def.engine_capacity and matched_def.engine_capacity > 0 else None,
|
||||
)
|
||||
db.add(catalog_entry)
|
||||
await db.flush()
|
||||
new_asset.catalog_id = catalog_entry.id
|
||||
await AssetMatcherService.enrich_asset_from_definition(db, new_asset, matched_def, conf)
|
||||
await db.flush()
|
||||
|
||||
@@ -348,9 +370,15 @@ class AssetService:
|
||||
|
||||
@staticmethod
|
||||
async def get_models(db: AsyncSession, make: str, vehicle_class: str = None) -> List[str]:
|
||||
"""Get all distinct models for a given make, optionally filtered by vehicle_class."""
|
||||
"""Get all distinct models for a given make, optionally filtered by vehicle_class.
|
||||
|
||||
Fuzzy matching: removes spaces and lowercases both the query and stored values
|
||||
so that 'CB 1000' matches 'CB1000'.
|
||||
"""
|
||||
stmt = select(distinct(VehicleModelDefinition.marketing_name)).where(
|
||||
VehicleModelDefinition.make == make
|
||||
func.replace(func.lower(VehicleModelDefinition.make), ' ', '').ilike(
|
||||
f'%{make.replace(" ", "").lower()}%'
|
||||
)
|
||||
)
|
||||
if vehicle_class:
|
||||
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
|
||||
@@ -384,6 +412,78 @@ class AssetService:
|
||||
engines = result.scalars().all()
|
||||
return engines
|
||||
|
||||
# --- CASCADING CATALOG METHODS (for wizard autocomplete) ---
|
||||
@staticmethod
|
||||
async def get_catalog_brands(db: AsyncSession, vehicle_class: Optional[str] = None, query: Optional[str] = None) -> List[str]:
|
||||
"""Get all distinct brands (makes) from vehicle_model_definitions, with optional class filter and search query.
|
||||
|
||||
Fuzzy matching: removes spaces and lowercases both the query and stored values
|
||||
so that 'BMW' matches 'bmw' and 'CB 1000' matches 'CB1000'.
|
||||
"""
|
||||
stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make)
|
||||
if vehicle_class:
|
||||
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
|
||||
if query:
|
||||
clean_query = query.replace(' ', '').lower()
|
||||
stmt = stmt.where(
|
||||
func.replace(func.lower(VehicleModelDefinition.make), ' ', '').ilike(f'%{clean_query}%')
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
brands = result.scalars().all()
|
||||
return [b for b in brands if b]
|
||||
|
||||
@staticmethod
|
||||
async def get_catalog_models(db: AsyncSession, brand: str, vehicle_class: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get all distinct models for a given brand, returning id and marketing_name."""
|
||||
stmt = select(
|
||||
VehicleModelDefinition.id,
|
||||
VehicleModelDefinition.marketing_name
|
||||
).where(
|
||||
VehicleModelDefinition.make == brand
|
||||
).distinct(VehicleModelDefinition.marketing_name).order_by(VehicleModelDefinition.marketing_name)
|
||||
if vehicle_class:
|
||||
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
return [{"id": r.id, "name": r.marketing_name} for r in rows if r.marketing_name]
|
||||
|
||||
@staticmethod
|
||||
async def get_catalog_years(db: AsyncSession, brand: str, model: str) -> List[int]:
|
||||
"""Get all distinct years for a given brand and model."""
|
||||
stmt = select(distinct(VehicleModelDefinition.year_from)).where(
|
||||
VehicleModelDefinition.make == brand,
|
||||
VehicleModelDefinition.marketing_name == model,
|
||||
VehicleModelDefinition.year_from > 0
|
||||
).order_by(VehicleModelDefinition.year_from.desc())
|
||||
result = await db.execute(stmt)
|
||||
years = result.scalars().all()
|
||||
return [y for y in years if y]
|
||||
|
||||
@staticmethod
|
||||
async def get_catalog_trims(db: AsyncSession, brand: str, model: str, year: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""Get all trim/variant details for a given brand, model, and optional year."""
|
||||
stmt = select(VehicleModelDefinition).where(
|
||||
VehicleModelDefinition.make == brand,
|
||||
VehicleModelDefinition.marketing_name == model
|
||||
)
|
||||
if year and year > 0:
|
||||
stmt = stmt.where(VehicleModelDefinition.year_from == year)
|
||||
stmt = stmt.order_by(VehicleModelDefinition.trim_level, VehicleModelDefinition.engine_capacity)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"trim_level": r.trim_level or '',
|
||||
"engine_capacity": r.engine_capacity or 0,
|
||||
"power_kw": r.power_kw or 0,
|
||||
"fuel_type": r.fuel_type,
|
||||
"year_from": r.year_from,
|
||||
"body_type": r.body_type or '',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
|
||||
"""
|
||||
@@ -596,15 +696,13 @@ class AssetService:
|
||||
user_badge = UserBadge(
|
||||
user_id=user_id,
|
||||
badge_id=badge.id,
|
||||
awarded_at=datetime.utcnow()
|
||||
earned_at=datetime.utcnow()
|
||||
)
|
||||
db.add(user_badge)
|
||||
await db.flush()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Awarded 'First Car' badge to user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error awarding first car badge: {e}")
|
||||
# Don't raise the error - badge awarding shouldn't break vehicle creation
|
||||
@@ -9,8 +9,8 @@ from sqlalchemy.orm import joinedload
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
|
||||
from app.models import UserStats
|
||||
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet, OneTimePassword
|
||||
from app.models import UserStats, LogSeverity
|
||||
from app.models.marketplace import Organization, OrganizationMember, OrgType, Branch
|
||||
from app.schemas.auth import UserLiteRegister, UserKYCComplete
|
||||
from app.core.security import get_password_hash, verify_password, generate_secure_slug
|
||||
@@ -146,7 +146,7 @@ class AuthService:
|
||||
# Sentinel Audit Log
|
||||
await security_service.log_event(
|
||||
db, user_id=new_user.id, action="USER_REGISTER_LITE",
|
||||
severity="INFO", target_type="User", target_id=str(new_user.id),
|
||||
severity=LogSeverity.info, target_type="User", target_id=str(new_user.id),
|
||||
new_data={"email": user_in.email}
|
||||
)
|
||||
|
||||
@@ -285,8 +285,8 @@ class AuthService:
|
||||
if kyc_in.region_code:
|
||||
user.region_code = kyc_in.region_code
|
||||
|
||||
# Gamification XP jóváírás
|
||||
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION")
|
||||
# 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)
|
||||
|
||||
# P2P Referral XP a meghívónak (ha van referred_by_id)
|
||||
if user.referred_by_id:
|
||||
@@ -295,7 +295,8 @@ class AuthService:
|
||||
db,
|
||||
user_id=user.referred_by_id,
|
||||
amount=int(p2p_xp),
|
||||
reason="P2P_REFERRAL_SUCCESS"
|
||||
reason="P2P_REFERRAL_SUCCESS",
|
||||
commit=False
|
||||
)
|
||||
logger.info(f"P2P XP ({p2p_xp}) awarded to referrer ID {user.referred_by_id} for user {user.id}")
|
||||
|
||||
@@ -341,11 +342,15 @@ class AuthService:
|
||||
|
||||
user.is_active = True
|
||||
|
||||
# Activate person
|
||||
person_stmt = select(Person).where(Person.user_id == user.id)
|
||||
person = (await db.execute(person_stmt)).scalar_one_or_none()
|
||||
if person:
|
||||
person.is_active = True
|
||||
# Activate person - FIX: Use User.person_id instead of Person.user_id
|
||||
# Person.user_id is a separate FK that may be NULL
|
||||
if user.person_id:
|
||||
person_stmt = select(Person).where(Person.id == user.person_id)
|
||||
person = (await db.execute(person_stmt)).scalar_one_or_none()
|
||||
if person:
|
||||
person.is_active = True
|
||||
# Also set the back-reference so Person.user_id is consistent
|
||||
person.user_id = user.id
|
||||
|
||||
await db.commit()
|
||||
return user # Return the activated User object
|
||||
@@ -379,20 +384,23 @@ class AuthService:
|
||||
))
|
||||
|
||||
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
|
||||
logger.info(f"Email küldés indítása (password reset) ide: {email}...")
|
||||
result = await email_manager.send_email(
|
||||
recipient=email, template_key="pwd_reset",
|
||||
variables={"link": link}, lang=user.preferred_language
|
||||
)
|
||||
logger.info(f"Email küldés eredménye (password reset): {result}")
|
||||
|
||||
# Ha az email küldés hibát dobott, ne titkoljuk el
|
||||
if result.get("status") == "error":
|
||||
logger.error(f"Password reset email FAILED for {email}: {result.get('message')}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Email küldési hiba: {result.get('message')}"
|
||||
# Mock email sending: log the reset link to console instead of sending real email
|
||||
logger.info(f"*** MOCK EMAIL *** Password reset for {email}")
|
||||
logger.info(f"*** MOCK EMAIL *** Reset link: {link}")
|
||||
logger.info(f"*** MOCK EMAIL *** Token: {token_val}")
|
||||
|
||||
# Try to send real email, but don't crash if SMTP is unavailable
|
||||
try:
|
||||
result = await email_manager.send_email(
|
||||
recipient=email, template_key="pwd_reset",
|
||||
variables={"link": link}, lang=user.preferred_language
|
||||
)
|
||||
logger.info(f"Email küldés eredménye (password reset): {result}")
|
||||
if result and result.get("status") == "error":
|
||||
logger.warning(f"Password reset email sending failed (non-critical): {result.get('message')}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Password reset email sending exception (non-critical): {e}")
|
||||
|
||||
await db.commit()
|
||||
return "success"
|
||||
@@ -424,7 +432,177 @@ class AuthService:
|
||||
return True
|
||||
except HTTPException:
|
||||
raise
|
||||
except: return False
|
||||
except Exception as e:
|
||||
logger.error(f"Password reset error: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def _generate_otp_code() -> str:
|
||||
"""Generate a random 6-digit OTP code."""
|
||||
import random
|
||||
return f"{random.randint(0, 999999):06d}"
|
||||
|
||||
@staticmethod
|
||||
async def request_account_restore(db: AsyncSession, email: str):
|
||||
"""
|
||||
30 napos fiókvisszaállítás: OTP kérés.
|
||||
Megkeresi a törölt felhasználót az eredeti email alapján.
|
||||
Ha 30 napon belül van a törlés, generál 6-jegyű kódot.
|
||||
Ha 30 napon TÚL van, jelzi, hogy új regisztráció szükséges.
|
||||
"""
|
||||
try:
|
||||
# Search for deleted user by checking if email exists in deleted prefix pattern
|
||||
stmt = select(User).where(
|
||||
User.email.like(f"%{email}"),
|
||||
User.is_deleted == True
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return {"status": "not_found", "message": "Nem található törölt fiók ezzel az email címmel."}
|
||||
|
||||
# Check if within 30-day window
|
||||
if user.deleted_at:
|
||||
days_since_deletion = (datetime.now(timezone.utc) - user.deleted_at).days
|
||||
else:
|
||||
days_since_deletion = 0
|
||||
|
||||
if days_since_deletion > 30:
|
||||
return {
|
||||
"status": "expired",
|
||||
"message": "A fiók véglegesen megsemmisült. Kérjük, regisztráljon újra ugyanazzal az email címmel.",
|
||||
"can_re_register": True,
|
||||
"original_email": email
|
||||
}
|
||||
|
||||
# Generate 6-digit OTP code
|
||||
code = await AuthService._generate_otp_code()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
|
||||
# Store OTP
|
||||
otp = OneTimePassword(
|
||||
email=email,
|
||||
code=code,
|
||||
otp_type="restore",
|
||||
extra_data={"user_id": user.id},
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(otp)
|
||||
await db.commit()
|
||||
|
||||
# Mock email sending: log the OTP code to console instead of sending real email
|
||||
logger.info(f"*** MOCK EMAIL *** OTP Code for {email}: {code}")
|
||||
logger.info(f"*** MOCK EMAIL *** Expires at: {expires_at.isoformat()}")
|
||||
|
||||
return {
|
||||
"status": "otp_sent",
|
||||
"message": "Egy 6-jegyű visszaállítási kódot küldtünk az email címére.",
|
||||
"expires_at": expires_at.isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Account restore request error for {email}: {e}")
|
||||
return {"status": "error", "message": "Hiba történt a visszaállítási kód generálása során."}
|
||||
|
||||
@staticmethod
|
||||
async def verify_account_restore(db: AsyncSession, email: str, code: str):
|
||||
"""
|
||||
OTP kód ellenőrzése és fiók visszaállítása.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Find valid OTP
|
||||
stmt = select(OneTimePassword).where(
|
||||
OneTimePassword.email == email,
|
||||
OneTimePassword.code == code,
|
||||
OneTimePassword.otp_type == "restore",
|
||||
OneTimePassword.is_used == False,
|
||||
OneTimePassword.expires_at > now
|
||||
).order_by(OneTimePassword.created_at.desc()).limit(1)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
otp = result.scalar_one_or_none()
|
||||
|
||||
if not otp:
|
||||
return {"status": "invalid", "message": "Érvénytelen vagy lejárt kód."}
|
||||
|
||||
# Mark OTP as used
|
||||
otp.is_used = True
|
||||
|
||||
# Find the deleted user
|
||||
user_id = otp.extra_data.get("user_id") if otp.extra_data else None
|
||||
if not user_id:
|
||||
return {"status": "error", "message": "Hiba a fiók azonosításában."}
|
||||
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return {"status": "error", "message": "Felhasználó nem található."}
|
||||
|
||||
# Restore the account
|
||||
user.is_deleted = False
|
||||
user.is_active = True
|
||||
user.deleted_at = None
|
||||
|
||||
# Restore original email (remove deleted_ prefix)
|
||||
# The email format is: deleted_{id}_{timestamp}_{original_email}
|
||||
if user.email.startswith("deleted_"):
|
||||
parts = user.email.split("_", 3)
|
||||
if len(parts) == 4:
|
||||
user.email = parts[3]
|
||||
|
||||
await security_service.log_event(
|
||||
db, user_id=user.id, action="USER_ACCOUNT_RESTORE",
|
||||
severity=LogSeverity.info, target_type="User", target_id=str(user.id),
|
||||
new_data={"restored_via": "otp", "email": user.email}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "message": "Fiók sikeresen visszaállítva."}
|
||||
|
||||
@staticmethod
|
||||
async def check_is_last_admin(db: AsyncSession, user_id: int) -> bool:
|
||||
"""
|
||||
Ellenőrzi, hogy a felhasználó az egyetlen aktív admin bármelyik szervezetében.
|
||||
Visszaadja: is_last_admin: bool
|
||||
"""
|
||||
try:
|
||||
from app.models.marketplace.organization import OrganizationMember, OrgUserRole
|
||||
|
||||
# Find organizations where user is ADMIN or OWNER
|
||||
stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER])
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
org_ids = [row[0] for row in result.all()]
|
||||
|
||||
if not org_ids:
|
||||
return False
|
||||
|
||||
# For each org, check if there are other active admins/owners
|
||||
for org_id in org_ids:
|
||||
stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER]),
|
||||
OrganizationMember.user_id != user_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
other_admins = result.scalars().all()
|
||||
|
||||
if not other_admins:
|
||||
# This org has no other admin - user is last admin here
|
||||
return True
|
||||
|
||||
return False
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"check_is_last_admin error: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def resend_verification(db: AsyncSession, email: str):
|
||||
@@ -479,20 +657,44 @@ class AuthService:
|
||||
|
||||
@staticmethod
|
||||
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
|
||||
""" Felhasználó törlése (Soft-Delete) auditálással. """
|
||||
"""
|
||||
Felhasználó törlése (Soft-Delete) auditálással, Person-preserving logikával.
|
||||
|
||||
Üzleti szabályok:
|
||||
1. User: is_active=False, is_deleted=True, deleted_at=utcnow
|
||||
2. Email átírás: deleted_{id}_{timestamp}_{original_email}
|
||||
3. Person rekordot NEM bántja (megőrizzük a jövőbeli újraaktiváláshoz)
|
||||
4. Wallet és Gamification adatok megőrzése (inaktív user miatt freeze)
|
||||
5. Refresh token-ek érvénytelenítése (JWT-alapú, így a deleted_at middleware ellenőrzi)
|
||||
"""
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not user or user.is_deleted: return False
|
||||
if not user or user.is_deleted:
|
||||
return False
|
||||
|
||||
# 1. Anonymize email with timestamp
|
||||
old_email = user.email
|
||||
user.email = f"deleted_{user.id}_{datetime.now().strftime('%Y%m%d')}_{old_email}"
|
||||
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
|
||||
user.email = f"deleted_{user.id}_{timestamp}_{old_email}"
|
||||
|
||||
# 2. Set soft-delete flags
|
||||
user.is_deleted = True
|
||||
user.is_active = False
|
||||
|
||||
user.deleted_at = datetime.now(timezone.utc)
|
||||
|
||||
# 3. CRITICAL: Do NOT touch Person record
|
||||
# Person.is_active stays as-is, Person data stays intact
|
||||
# This allows future re-activation with the same identity
|
||||
|
||||
# 4. Security audit log with deleted_at info
|
||||
await security_service.log_event(
|
||||
db, user_id=actor_id, action="USER_SOFT_DELETE",
|
||||
severity="WARNING", target_type="User", target_id=str(user_id),
|
||||
new_data={"reason": reason}
|
||||
severity=LogSeverity.warning, target_type="User", target_id=str(user_id),
|
||||
new_data={
|
||||
"reason": reason,
|
||||
"deleted_at": str(user.deleted_at),
|
||||
"person_preserved": True
|
||||
}
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
return True
|
||||
|
||||
@@ -18,28 +18,40 @@ class GamificationService:
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0):
|
||||
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz. """
|
||||
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True):
|
||||
""" 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).
|
||||
"""
|
||||
service = GamificationService()
|
||||
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason)
|
||||
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit)
|
||||
|
||||
async def process_activity(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
xp_amount: int,
|
||||
social_amount: int,
|
||||
reason: str,
|
||||
is_penalty: bool = False
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
xp_amount: int,
|
||||
social_amount: int,
|
||||
reason: str,
|
||||
is_penalty: bool = False,
|
||||
commit: bool = True
|
||||
):
|
||||
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés. """
|
||||
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés.
|
||||
|
||||
Args:
|
||||
commit: If True (default), commits the transaction internally.
|
||||
Set to False when called from within a larger transaction.
|
||||
"""
|
||||
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={
|
||||
"xp_logic": {"base_xp": 500, "exponent": 1.5},
|
||||
"penalty_logic": {
|
||||
"recovery_rate": 0.5,
|
||||
"recovery_rate": 0.5,
|
||||
"thresholds": {"level_1": 100, "level_2": 500, "level_3": 1000},
|
||||
"multipliers": {"L0": 1.0, "L1": 0.5, "L2": 0.1, "L3": 0.0}
|
||||
},
|
||||
@@ -98,12 +110,15 @@ class GamificationService:
|
||||
# 7. NAPLÓZÁS
|
||||
db.add(PointsLedger(user_id=user_id, points=final_xp, reason=reason))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(stats)
|
||||
# Only commit if caller wants us to manage the transaction
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(stats)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
if commit:
|
||||
await db.rollback()
|
||||
logger.error(f"Gamification Error for user {user_id}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
@@ -66,8 +66,10 @@ class MaintenanceService:
|
||||
alerts_generated = 0
|
||||
for asset, telemetry in result.all():
|
||||
# A Robot 3 által feltöltött gyári adat (pl. 15.000 km)
|
||||
interval = asset.factory_data.get("oil_change_km") if asset.factory_data else None
|
||||
last_service_km = asset.factory_data.get("last_service_km", 0)
|
||||
# A factory_data az AssetCatalog (vehicle_catalog) táblában van
|
||||
catalog_data = asset.catalog.factory_data if asset.catalog else {}
|
||||
interval = catalog_data.get("oil_change_km") if catalog_data else None
|
||||
last_service_km = catalog_data.get("last_service_km", 0)
|
||||
|
||||
if interval and telemetry.current_mileage:
|
||||
next_service_due = last_service_km + interval
|
||||
|
||||
@@ -1,213 +1,15 @@
|
||||
"""
|
||||
Smart Odometer Service - Adminisztrátor által paraméterezhető kilométeróra becslés.
|
||||
[DEPRECATED] Smart Odometer Service - VehicleOdometerState model removed.
|
||||
|
||||
A szolgáltatás a járművek kilométeróra állását becsüli a költségbejegyzések alapján,
|
||||
figyelembe véve a rendszerparamétereket (ODOMETER_MIN_DAYS_FOR_AVG, ODOMETER_CONFIDENCE_THRESHOLD).
|
||||
Ha az admin beállított manuális átlagot (manual_override_avg), akkor azt használja.
|
||||
This service is deprecated. The VehicleOdometerState model has been removed
|
||||
from the schema. Odometer tracking is now handled directly via the
|
||||
OdometerReading table (audit trail) and Asset.current_mileage field.
|
||||
|
||||
The predictive odometer state estimation logic has been removed to keep
|
||||
the entity model clean. Use OdometerReading records directly for
|
||||
mileage audit trail purposes.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.vehicle import VehicleOdometerState, VehicleCost
|
||||
from app.models.system import SystemParameter
|
||||
from app.models import VehicleModelDefinition
|
||||
|
||||
|
||||
class OdometerService:
|
||||
"""Kilométeróra becslési szolgáltatás adminisztrációs kontrollal."""
|
||||
|
||||
@staticmethod
|
||||
async def get_system_param(db: AsyncSession, key: str, default_value):
|
||||
"""Rendszerparaméter lekérése a system.system_parameters táblából."""
|
||||
stmt = select(SystemParameter).where(
|
||||
SystemParameter.key == key,
|
||||
SystemParameter.scope_level == 'global',
|
||||
SystemParameter.is_active == True
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
param = result.scalars().first()
|
||||
if param and 'value' in param.value:
|
||||
return param.value['value']
|
||||
return default_value
|
||||
|
||||
@staticmethod
|
||||
async def update_vehicle_stats(db: AsyncSession, vehicle_id: int) -> Optional[VehicleOdometerState]:
|
||||
"""
|
||||
Frissíti a jármű kilométeróra statisztikáit.
|
||||
|
||||
Algoritmus:
|
||||
1. Ha van manual_override_avg, használja azt.
|
||||
2. Különben számol átlagot a vehicle.costs bejegyzésekből.
|
||||
3. Figyelembe veszi az ODOMETER_MIN_DAYS_FOR_AVG paramétert.
|
||||
4. Kiszámolja a confidence_score-t a minták száma alapján.
|
||||
5. Frissíti vagy létrehozza a VehicleOdometerState rekordot.
|
||||
"""
|
||||
# Rendszerparaméterek lekérése
|
||||
min_days = await OdometerService.get_system_param(db, 'ODOMETER_MIN_DAYS_FOR_AVG', 7)
|
||||
confidence_threshold = await OdometerService.get_system_param(db, 'ODOMETER_CONFIDENCE_THRESHOLD', 0.5)
|
||||
|
||||
# Meglévő állapot lekérése
|
||||
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
|
||||
result = await db.execute(stmt)
|
||||
odometer_state = result.scalars().first()
|
||||
|
||||
# Költségbejegyzések lekérése dátum és odometer szerint rendezve
|
||||
cost_stmt = select(VehicleCost).where(
|
||||
VehicleCost.vehicle_id == vehicle_id,
|
||||
VehicleCost.odometer.isnot(None)
|
||||
).order_by(VehicleCost.date.asc())
|
||||
|
||||
cost_result = await db.execute(cost_stmt)
|
||||
costs = cost_result.scalars().all()
|
||||
|
||||
if not costs:
|
||||
# Nincs adat, alapértelmezett értékek
|
||||
if odometer_state:
|
||||
odometer_state.daily_avg_distance = 0
|
||||
odometer_state.confidence_score = 0
|
||||
odometer_state.estimated_current_odometer = odometer_state.last_recorded_odometer
|
||||
else:
|
||||
# Jármű alapadatok lekérése
|
||||
vehicle_stmt = select(VehicleModelDefinition).where(VehicleModelDefinition.id == vehicle_id)
|
||||
vehicle_result = await db.execute(vehicle_stmt)
|
||||
vehicle = vehicle_result.scalars().first()
|
||||
|
||||
if not vehicle:
|
||||
return None
|
||||
|
||||
odometer_state = VehicleOdometerState(
|
||||
vehicle_id=vehicle_id,
|
||||
last_recorded_odometer=0,
|
||||
last_recorded_date=datetime.now(),
|
||||
daily_avg_distance=0,
|
||||
estimated_current_odometer=0,
|
||||
confidence_score=0,
|
||||
manual_override_avg=None
|
||||
)
|
||||
db.add(odometer_state)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(odometer_state)
|
||||
return odometer_state
|
||||
|
||||
# Utolsó rögzített adatok
|
||||
last_cost = costs[-1]
|
||||
last_recorded_odometer = last_cost.odometer
|
||||
last_recorded_date = last_cost.date
|
||||
|
||||
# Manuális átlag ellenőrzése
|
||||
if odometer_state and odometer_state.manual_override_avg is not None:
|
||||
daily_avg = float(odometer_state.manual_override_avg)
|
||||
confidence = 1.0 # Manuális beállítás esetén teljes bizalom
|
||||
else:
|
||||
# Átlag számítása a költségbejegyzésekből
|
||||
valid_pairs = []
|
||||
for i in range(1, len(costs)):
|
||||
prev = costs[i-1]
|
||||
curr = costs[i]
|
||||
|
||||
days_diff = (curr.date - prev.date).days
|
||||
km_diff = curr.odometer - prev.odometer
|
||||
|
||||
if days_diff >= min_days and km_diff > 0:
|
||||
daily_avg = km_diff / days_diff
|
||||
valid_pairs.append((daily_avg, days_diff))
|
||||
|
||||
if valid_pairs:
|
||||
# Súlyozott átlag (hosszabb időszakok nagyobb súllyal)
|
||||
total_weighted = sum(avg * weight for avg, weight in valid_pairs)
|
||||
total_days = sum(weight for _, weight in valid_pairs)
|
||||
daily_avg = total_weighted / total_days if total_days > 0 else 0
|
||||
|
||||
# Confidence score: érvényes párok száma / összes lehetséges párok
|
||||
confidence = min(len(valid_pairs) / max(len(costs) - 1, 1), 1.0)
|
||||
else:
|
||||
daily_avg = 0
|
||||
confidence = 0
|
||||
|
||||
# Becsült jelenlegi kilométer
|
||||
days_since_last = (datetime.now(last_recorded_date.tzinfo) - last_recorded_date).days
|
||||
estimated_odometer = last_recorded_odometer + (daily_avg * max(days_since_last, 0))
|
||||
|
||||
# Állapot frissítése vagy létrehozása
|
||||
if odometer_state:
|
||||
odometer_state.last_recorded_odometer = last_recorded_odometer
|
||||
odometer_state.last_recorded_date = last_recorded_date
|
||||
odometer_state.daily_avg_distance = daily_avg
|
||||
odometer_state.estimated_current_odometer = estimated_odometer
|
||||
odometer_state.confidence_score = confidence
|
||||
else:
|
||||
odometer_state = VehicleOdometerState(
|
||||
vehicle_id=vehicle_id,
|
||||
last_recorded_odometer=last_recorded_odometer,
|
||||
last_recorded_date=last_recorded_date,
|
||||
daily_avg_distance=daily_avg,
|
||||
estimated_current_odometer=estimated_odometer,
|
||||
confidence_score=confidence,
|
||||
manual_override_avg=None
|
||||
)
|
||||
db.add(odometer_state)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(odometer_state)
|
||||
return odometer_state
|
||||
|
||||
@staticmethod
|
||||
async def get_estimated_odometer(db: AsyncSession, vehicle_id: int) -> Tuple[Optional[float], float]:
|
||||
"""
|
||||
Visszaadja a jármű becsült jelenlegi kilométeróra állását és a bizalom pontszámot.
|
||||
|
||||
Returns:
|
||||
Tuple[estimated_odometer, confidence_score]
|
||||
"""
|
||||
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
|
||||
result = await db.execute(stmt)
|
||||
odometer_state = result.scalars().first()
|
||||
|
||||
if not odometer_state:
|
||||
# Ha nincs állapot, frissítsük
|
||||
odometer_state = await OdometerService.update_vehicle_stats(db, vehicle_id)
|
||||
if not odometer_state:
|
||||
return None, 0.0
|
||||
|
||||
return odometer_state.estimated_current_odometer, odometer_state.confidence_score
|
||||
|
||||
@staticmethod
|
||||
async def set_manual_override(db: AsyncSession, vehicle_id: int, daily_avg: Optional[float]) -> Optional[VehicleOdometerState]:
|
||||
"""
|
||||
Adminisztrátori manuális átlag beállítása.
|
||||
|
||||
Args:
|
||||
daily_avg: Napi átlagos kilométer (km/nap). Ha None, törli a manuális beállítást.
|
||||
"""
|
||||
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
|
||||
result = await db.execute(stmt)
|
||||
odometer_state = result.scalars().first()
|
||||
|
||||
if not odometer_state:
|
||||
# Ha nincs állapot, hozzuk létre
|
||||
odometer_state = VehicleOdometerState(
|
||||
vehicle_id=vehicle_id,
|
||||
last_recorded_odometer=0,
|
||||
last_recorded_date=datetime.now(),
|
||||
daily_avg_distance=0,
|
||||
estimated_current_odometer=0,
|
||||
confidence_score=0,
|
||||
manual_override_avg=daily_avg
|
||||
)
|
||||
db.add(odometer_state)
|
||||
else:
|
||||
odometer_state.manual_override_avg = daily_avg
|
||||
# Frissítsük a becslést a manuális átlaggal
|
||||
if daily_avg is not None:
|
||||
days_since_last = (datetime.now(odometer_state.last_recorded_date.tzinfo) - odometer_state.last_recorded_date).days
|
||||
odometer_state.estimated_current_odometer = odometer_state.last_recorded_odometer + (daily_avg * max(days_since_last, 0))
|
||||
odometer_state.confidence_score = 1.0
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(odometer_state)
|
||||
return odometer_state
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("OdometerService is deprecated. VehicleOdometerState model has been removed.")
|
||||
|
||||
162
backend/app/services/subscription_service.py
Normal file
162
backend/app/services/subscription_service.py
Normal file
@@ -0,0 +1,162 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/subscription_service.py
|
||||
"""
|
||||
Subscription & Entitlement Service — Premium költségkategória láthatóság.
|
||||
|
||||
A szolgáltatás a felhasználó előfizetési szintje (tier) alapján szabályozza,
|
||||
hogy mely költségkategóriák (CostCategory) érhetők el. A tier-ek hierarchikusak:
|
||||
free < premium < enterprise.
|
||||
|
||||
Hierarchia:
|
||||
- free: alap kategóriák (üzemanyag, szerviz, gumik, biztosítás, adók)
|
||||
- premium: free + útdíj/parkolás, ápolás, egyéb
|
||||
- enterprise: premium + minden (nincs korlátozás)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.vehicle.vehicle import CostCategory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Subscription Feature Matrix ──────────────────────────────────────────────
|
||||
# Minden funkció/kategória minimális tier szintje.
|
||||
# A tier-ek: free < premium < enterprise
|
||||
TIER_HIERARCHY = {
|
||||
"free": 0,
|
||||
"premium": 1,
|
||||
"enterprise": 2,
|
||||
}
|
||||
|
||||
SUBSCRIPTION_FEATURES: dict[str, str] = {
|
||||
# Feature -> minimum required tier
|
||||
"cost_category:fuel": "free",
|
||||
"cost_category:service": "free",
|
||||
"cost_category:tires": "free",
|
||||
"cost_category:insurance": "free",
|
||||
"cost_category:taxes": "free",
|
||||
"cost_category:toll_parking": "premium",
|
||||
"cost_category:cleaning": "premium",
|
||||
"cost_category:other": "premium",
|
||||
"cost_category:all": "enterprise",
|
||||
# Feature flags
|
||||
"export_csv": "free",
|
||||
"analytics_tco": "premium",
|
||||
"analytics_detailed": "enterprise",
|
||||
"api_access": "free",
|
||||
"multi_vehicle": "free",
|
||||
"unlimited_vehicles": "premium",
|
||||
}
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
"""
|
||||
Entitlement Mátrix — előfizetési szintek kezelése.
|
||||
|
||||
Metódusok:
|
||||
get_user_tier(db, user_id) -> str
|
||||
can_access_feature(tier, feature_key) -> bool
|
||||
get_visible_categories(db, tier) -> list[CostCategory]
|
||||
require_tier(tier, required_feature_or_min_tier) -> bool
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _tier_to_int(tier: str) -> int:
|
||||
"""Tier string konvertálása numerikus értékre a hierarchiában."""
|
||||
return TIER_HIERARCHY.get(tier.lower(), 0)
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tier(db: AsyncSession, user_id: int) -> str:
|
||||
"""
|
||||
Visszaadja a felhasználó aktuális előfizetési szintjét.
|
||||
|
||||
Jelenlegi implementáció: minden user 'free' tier-t kap.
|
||||
A jövőben ez a subscription/org táblákból lesz lekérdezve.
|
||||
"""
|
||||
# TODO: Éles implementáció — subscription tábla lekérdezése
|
||||
# TODO: Szervezeti tier felülírás (pl. enterprise org)
|
||||
return "free"
|
||||
|
||||
@staticmethod
|
||||
def can_access_feature(user_tier: str, feature_key: str) -> bool:
|
||||
"""
|
||||
Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz.
|
||||
|
||||
Args:
|
||||
user_tier: A felhasználó tier szintje (free/premium/enterprise)
|
||||
feature_key: A funkció kulcsa (pl. 'cost_category:fuel', 'analytics_tco')
|
||||
|
||||
Returns:
|
||||
bool: True ha hozzáfér, False ha nem
|
||||
"""
|
||||
required_tier = SUBSCRIPTION_FEATURES.get(feature_key)
|
||||
if required_tier is None:
|
||||
# Ismeretlen feature — alapértelmezetten tiltva
|
||||
logger.warning(f"Unknown feature key: {feature_key}")
|
||||
return False
|
||||
|
||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
||||
required_level = SubscriptionService._tier_to_int(required_tier)
|
||||
return user_level >= required_level
|
||||
|
||||
@staticmethod
|
||||
async def get_visible_categories(
|
||||
db: AsyncSession,
|
||||
user_tier: str
|
||||
) -> List[CostCategory]:
|
||||
"""
|
||||
Visszaadja azokat a CostCategory-ket, amelyek a user tier-jének megfelelnek.
|
||||
|
||||
Szűrési logika:
|
||||
- A kategória min_tier mezője alapján szűrünk.
|
||||
- Ha a kategória min_tier értéke kisebb vagy egyenlő, mint a user tier,
|
||||
akkor látható.
|
||||
- Ha a min_tier nincs beállítva (None), akkor 'free'-ként kezeljük.
|
||||
|
||||
Args:
|
||||
db: Adatbázis session
|
||||
user_tier: A felhasználó tier szintje
|
||||
|
||||
Returns:
|
||||
List[CostCategory]: A látható kategóriák listája
|
||||
"""
|
||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
||||
|
||||
# Lekérjük az összes kategóriát
|
||||
stmt = select(CostCategory).order_by(CostCategory.id)
|
||||
result = await db.execute(stmt)
|
||||
all_categories = result.scalars().all()
|
||||
|
||||
# Szűrés a min_tier alapján
|
||||
visible = []
|
||||
for cat in all_categories:
|
||||
cat_tier = getattr(cat, "min_tier", "free") or "free"
|
||||
cat_level = SubscriptionService._tier_to_int(cat_tier)
|
||||
if user_level >= cat_level:
|
||||
visible.append(cat)
|
||||
|
||||
return visible
|
||||
|
||||
@staticmethod
|
||||
def require_tier(user_tier: str, required_feature_or_min_tier: str) -> bool:
|
||||
"""
|
||||
Egyszerű tier ellenőrzés — dependency check-hez.
|
||||
|
||||
Args:
|
||||
user_tier: A felhasználó tier szintje
|
||||
required_feature_or_min_tier: Vagy egy feature kulcs, vagy egy tier név
|
||||
|
||||
Returns:
|
||||
bool: True ha megfelel, False ha nem
|
||||
"""
|
||||
# Először feature kulcsként próbáljuk
|
||||
if required_feature_or_min_tier in SUBSCRIPTION_FEATURES:
|
||||
return SubscriptionService.can_access_feature(
|
||||
user_tier, required_feature_or_min_tier
|
||||
)
|
||||
|
||||
# Ha nem feature kulcs, akkor tier névként kezeljük
|
||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
||||
required_level = SubscriptionService._tier_to_int(required_feature_or_min_tier)
|
||||
return user_level >= required_level
|
||||
Reference in New Issue
Block a user