feat: implement pivot-currency model, rbac smart tokens & fix circular imports

This commit is contained in:
2026-02-10 10:20:45 +00:00
parent 24d35fe0c1
commit e255fea3a5
117 changed files with 2247 additions and 3542 deletions

View File

@@ -0,0 +1,35 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.asset import Asset, AssetTelemetry, AssetFinancials
from app.models.gamification import UserStats, PointRule
import uuid
async def create_new_vehicle(db: AsyncSession, user_id: int, vin: str, license_plate: str):
# 1. Alap Asset létrehozása
new_asset = Asset(
vin=vin,
license_plate=license_plate,
name=f"Teszt Autó ({license_plate})"
)
db.add(new_asset)
await db.flush() # Hogy legyen ID-ja
# 2. Modulok inicializálása (Digital Twin alapozás)
db.add(AssetTelemetry(asset_id=new_asset.id, current_mileage=0))
db.add(AssetFinancials(asset_id=new_asset.id))
# 3. GAMIFICATION: Pontszerzés (ASSET_REGISTER = 100 XP)
# Megkeressük a szabályt
rule_stmt = select(PointRule).where(PointRule.action_key == "ASSET_REGISTER")
rule = (await db.execute(rule_stmt)).scalar_one_or_none()
if rule:
# Frissítjük a felhasználó XP-jét
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
if stats:
stats.total_xp += rule.points
# Itt később jöhet a szintlépés ellenőrzése is!
await db.commit()
return new_asset

View File

@@ -10,7 +10,7 @@ from sqlalchemy.orm import joinedload
from fastapi.encoders import jsonable_encoder
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
from app.models.gamification import UserStats # <--- Innen importáljuk mostantól!
from app.models.gamification import UserStats
from app.models.organization import Organization, OrganizationMember, OrgType
from app.schemas.auth import UserLiteRegister, UserKYCComplete
from app.core.security import get_password_hash, verify_password
@@ -26,7 +26,7 @@ class AuthService:
async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
"""
Step 1: Lite Regisztráció (Master Book 1.1)
Új User és ideiglenes Person rekord létrehozása.
Új User és ideiglenes Person rekord létrehozása nyelvi és időzóna adatokkal.
"""
try:
# Ideiglenes Person rekord a KYC-ig
@@ -45,7 +45,10 @@ class AuthService:
role=UserRole.user,
is_active=False,
is_deleted=False,
region_code=user_in.region_code
region_code=user_in.region_code,
# --- NYELVI ÉS ADMIN BEÁLLÍTÁSOK MENTÉSE ---
preferred_language=user_in.lang,
timezone=user_in.timezone
)
db.add(new_user)
await db.flush()
@@ -60,12 +63,14 @@ class AuthService:
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_hours))
))
# Email küldés (Master Book 3.2: Nincs manuális subject)
# --- EMAIL KÜLDÉSE A VÁLASZTOTT NYELVEN ---
# Master Book 3.2: Nincs manuális subject, a nyelvi kulcs alapján töltődik be
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
await email_manager.send_email(
recipient=user_in.email,
template_key="registration",
variables={"first_name": user_in.first_name, "link": verification_link}
template_key="reg", # hu.json: email.reg_subject, reg_greeting stb.
variables={"first_name": user_in.first_name, "link": verification_link},
lang=user_in.lang # Dinamikus nyelvválasztás
)
await db.commit()
@@ -81,6 +86,7 @@ class AuthService:
"""
1.3. Fázis: Atomi Tranzakció & Shadow Identity
Felismeri a visszatérő Person-t, de új User-ként, izolált flottával indít.
Frissíti a nyelvi és pénzügyi beállításokat.
"""
try:
# 1. Aktuális technikai User lekérése
@@ -89,8 +95,11 @@ class AuthService:
user = res.scalar_one_or_none()
if not user: return None
# 2. Shadow Identity Ellenőrzése (Anyja neve + Születési hely + Idő alapján)
# Globális keresés, régiótól függetlenül
# --- PÉNZNEM PREFERENCIA FRISSÍTÉSE ---
if hasattr(kyc_in, 'preferred_currency') and kyc_in.preferred_currency:
user.preferred_currency = kyc_in.preferred_currency
# 2. Shadow Identity Ellenőrzése
identity_stmt = select(Person).where(and_(
Person.mothers_last_name == kyc_in.mothers_last_name,
Person.mothers_first_name == kyc_in.mothers_first_name,
@@ -100,7 +109,6 @@ class AuthService:
existing_person = (await db.execute(identity_stmt)).scalar_one_or_none()
if existing_person:
# Visszatérő identitás: A User-t a régi Person-hoz kötjük
user.person_id = existing_person.id
active_person = existing_person
logger.info(f"Shadow Identity linked: User {user_id} -> Person {existing_person.id}")
@@ -118,7 +126,7 @@ class AuthService:
parcel_id=kyc_in.address_hrsz
)
# 4. Person adatok frissítése (mindig a legfrissebbet tároljuk)
# 4. Person adatok frissítése
active_person.mothers_last_name = kyc_in.mothers_last_name
active_person.mothers_first_name = kyc_in.mothers_first_name
active_person.birth_place = kyc_in.birth_place
@@ -129,7 +137,7 @@ class AuthService:
active_person.ice_contact = jsonable_encoder(kyc_in.ice_contact)
active_person.is_active = True
# 5. Új, izolált INDIVIDUAL szervezet (4.2.3)
# 5. Új, izolált INDIVIDUAL szervezet (4.2.3) i18n beállításokkal
new_org = Organization(
full_name=f"{active_person.last_name} {active_person.first_name} Egyéni Flotta",
name=f"{active_person.last_name} Flotta",
@@ -137,7 +145,11 @@ class AuthService:
owner_id=user.id,
is_transferable=False,
is_active=True,
status="verified"
status="verified",
# Megörökölt adminisztrációs adatok
language=user.preferred_language,
default_currency=user.preferred_currency,
country_code=user.region_code
)
db.add(new_org)
await db.flush()
@@ -150,8 +162,13 @@ class AuthService:
permissions={"can_add_asset": True, "can_view_costs": True, "is_admin": True}
))
# 7. Wallet & Stats (Friss kezdés 0 ponttal)
db.add(Wallet(user_id=user.id, coin_balance=0, credit_balance=0))
# 7. Wallet & Stats
db.add(Wallet(
user_id=user.id,
coin_balance=0,
credit_balance=0,
currency=user.preferred_currency
))
db.add(UserStats(user_id=user.id, total_xp=0, current_level=1))
# 8. Aktiválás
@@ -197,7 +214,6 @@ class AuthService:
@staticmethod
async def initiate_password_reset(db: AsyncSession, email: str):
# Csak aktív (nem törölt) felhasználónak engedünk jelszót resetelni
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
user = (await db.execute(stmt)).scalar_one_or_none()
@@ -211,11 +227,13 @@ class AuthService:
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_hours))
))
# --- EMAIL KÜLDÉSE A FELHASZNÁLÓ SAJÁT NYELVÉN ---
reset_link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
await email_manager.send_email(
recipient=email,
template_key="password_reset",
variables={"link": reset_link}
template_key="pwd_reset", # hu.json: email.pwd_reset_subject stb.
variables={"link": reset_link},
lang=user.preferred_language # Adatbázisból kinyert nyelv
)
await db.commit()
return "success"

View File

@@ -0,0 +1,97 @@
import logging
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.models.asset import AssetCost, AssetTelemetry, ExchangeRate
from app.models.gamification import UserStats
from app.models.system_config import SystemParameter
from app.schemas.asset_cost import AssetCostCreate
from datetime import datetime
logger = logging.getLogger(__name__)
class CostService:
@staticmethod
async def get_param(db: AsyncSession, key: str, default: any) -> any:
"""Rendszerparaméter lekérése (pl. XP szorzó)."""
stmt = select(SystemParameter).where(SystemParameter.key == key)
res = await db.execute(stmt)
param = res.scalar_one_or_none()
return param.value if param else default
async def record_cost(self, db: AsyncSession, cost_in: AssetCostCreate, user_id: int):
"""
Költség rögzítése: EUR konverzió + Telemetria + XP.
"""
try:
# 1. Árfolyam lekérése (EUR alapú pivot)
# Megkeressük a legfrissebb rögzített árfolyamot a megadott devizához
rate_stmt = select(ExchangeRate).where(
ExchangeRate.target_currency == cost_in.currency_local
).order_by(desc(ExchangeRate.updated_at)).limit(1)
rate_res = await db.execute(rate_stmt)
rate_obj = rate_res.scalar_one_or_none()
# Ha nincs rögzített árfolyam, 1.0-val számolunk (vagy hibát dobhatunk a konfigurációtól függően)
exchange_rate = rate_obj.rate if rate_obj else Decimal("1.0")
# EUR kalkuláció: Helyi összeg / Árfolyam (Pl. 40000 HUF / 400 = 100 EUR)
amt_eur = Decimal(str(cost_in.amount_local)) / exchange_rate if exchange_rate > 0 else Decimal("0")
# 2. Költség rekord létrehozása
new_cost = AssetCost(
asset_id=cost_in.asset_id,
organization_id=cost_in.organization_id,
driver_id=user_id,
cost_type=cost_in.cost_type,
amount_local=cost_in.amount_local,
currency_local=cost_in.currency_local,
amount_eur=amt_eur,
net_amount_local=cost_in.net_amount_local,
vat_rate=cost_in.vat_rate,
exchange_rate_used=exchange_rate,
mileage_at_cost=cost_in.mileage_at_cost,
date=cost_in.date or datetime.now(),
data=cost_in.data or {}
)
db.add(new_cost)
# 3. Telemetria frissítése (Ha érkezett kilométeróra állás)
if cost_in.mileage_at_cost:
tel_stmt = select(AssetTelemetry).where(AssetTelemetry.asset_id == cost_in.asset_id)
res = await db.execute(tel_stmt)
telemetry = res.scalar_one_or_none()
if telemetry:
# Megakadályozzuk a "visszatekerést"
if cost_in.mileage_at_cost > (telemetry.current_mileage or 0):
telemetry.current_mileage = cost_in.mileage_at_cost
else:
# Ha még nem volt telemetria adat, létrehozzuk
new_telemetry = AssetTelemetry(
asset_id=cost_in.asset_id,
current_mileage=cost_in.mileage_at_cost
)
db.add(new_telemetry)
# 4. Gamification XP jóváírás
xp_reward = await self.get_param(db, "XP_PER_COST_LOG", 50)
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
stats_res = await db.execute(stats_stmt)
user_stats = stats_res.scalar_one_or_none()
if user_stats:
user_stats.total_xp += int(xp_reward)
logger.info(f"User {user_id} earned {xp_reward} XP for cost logging.")
await db.commit()
await db.refresh(new_cost)
return new_cost
except Exception as e:
await db.rollback()
logger.error(f"Error in record_cost: {str(e)}")
raise e
cost_service = CostService()

View File

@@ -17,6 +17,9 @@ class EmailManager:
button_text = locale_manager.get(f"email.{template_key}_button", lang=lang)
footer = locale_manager.get(f"email.{template_key}_footer", lang=lang)
# ÚJ: A link fallback szöveg is a nyelvi fájlból jön
link_fallback_text = locale_manager.get("email.link_fallback", lang=lang)
return f"""
<html>
<body style="font-family: Arial, sans-serif; color: #333; line-height: 1.6;">
@@ -30,8 +33,8 @@ class EmailManager:
</a>
</div>
<p style="font-size: 0.85em; color: #777; word-break: break-all;">
Ha a gomb nem működik, másolja be ezt a linket a böngészőjébe:<br>
{variables.get('link')}
{link_fallback_text}<br>
<a href="{variables.get('link')}" style="color: #3498db;">{variables.get('link')}</a>
</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;">
<p style="font-size: 0.8em; color: #999; text-align: center;">{footer}</p>
@@ -66,18 +69,11 @@ class EmailManager:
response = sg.send(message)
logger.info(f"SendGrid Status: {response.status_code} for {recipient}")
if response.status_code >= 400:
logger.error(f"SendGrid Hibaüzenet: {response.body}")
return {"status": "success", "provider": "sendgrid", "code": response.status_code}
except Exception as e:
logger.error(f"SendGrid Kritikus Hiba: {str(e)}")
# 2. SMTP Fallback
if not settings.SMTP_HOST or not settings.SMTP_USER or not settings.SMTP_PASSWORD:
logger.warning("SMTP nincs konfigurálva a fallback-hez.")
return {"status": "error", "message": "Nincs elérhető szolgáltató."}
try:
msg = MIMEMultipart()
msg["From"] = f"{settings.EMAILS_FROM_NAME} <{settings.EMAILS_FROM_EMAIL}>"

View File

@@ -1,26 +1,47 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text
from sqlalchemy import select
from app.models.gamification import UserStats, PointsLedger
from app.models.identity import User
import math
class GamificationService:
@staticmethod
async def award_points(db: AsyncSession, user_id: int, points: int, reason: str):
"""Pontok jóváírása (SQL szinkronizált points mezővel)."""
new_entry = PointsLedger(
user_id=user_id,
points=points, # Javítva: points_change helyett points
reason=reason
)
db.add(new_entry)
result = await db.execute(select(UserStats).where(UserStats.user_id == user_id))
stats = result.scalar_one_or_none()
async def process_activity(db: AsyncSession, user_id: int, xp_amount: int, social_amount: int, reason: str):
"""
XP növelés, Szintlépés csekk és Automata Kredit váltás.
"""
# 1. User statisztika lekérése
stmt = select(UserStats).where(UserStats.user_id == user_id)
stats = (await db.execute(stmt)).scalar_one_or_none()
if not stats:
stats = UserStats(user_id=user_id, total_points=0, current_level=1)
stats = UserStats(user_id=user_id, total_xp=0, social_points=0, current_level=1, credits=0)
db.add(stats)
# 2. Részletes Logolás (PointsLedger) - A visszakövethetőség miatt
db.add(PointsLedger(
user_id=user_id,
xp_gain=xp_amount,
social_gain=social_amount,
reason=reason
))
# 3. XP és Szintlépés (Nehezedő görbe)
stats.total_xp += xp_amount
# Képlet: Level = (XP / 500)^(1/1.5)
new_level = int((stats.total_xp / 500) ** (1/1.5)) + 1
if new_level > stats.current_level:
stats.current_level = new_level
# 4. Automata Kredit váltás
# Példa: Minden 100 Social pont automatikusan 1 Kredit lesz
stats.social_points += social_amount
if stats.social_points >= 100:
new_credits = stats.social_points // 100
stats.credits += new_credits
stats.social_points %= 100 # A maradék megmarad a következő váltáshoz
stats.total_points += points
await db.flush()
return stats.total_points
# Külön log a váltásról
db.add(PointsLedger(user_id=user_id, reason=f"Auto-conversion: {new_credits} Credits", credits_change=new_credits))
await db.commit()
return stats

View File

@@ -1,34 +1,45 @@
# /app/services/harvester_base.py
import httpx
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.vehicle import VehicleCatalog
from app.models.asset import AssetCatalog
logger = logging.getLogger(__name__)
class BaseHarvester:
def __init__(self, category: str):
self.category = category
self.category = category # car, bike, truck
self.headers = {"User-Agent": "ServiceFinder-Harvester-Bot/2.0"}
async def check_exists(self, db: AsyncSession, brand: str, model: str):
"""Ellenőrzi, hogy az adott modell létezik-e már."""
stmt = select(VehicleCatalog).where(
VehicleCatalog.brand == brand,
VehicleCatalog.model == model,
VehicleCatalog.category == self.category
async def check_exists(self, db: AsyncSession, brand: str, model: str, gen: str = None):
"""Ellenőrzi a katalógusban való létezést."""
stmt = select(AssetCatalog).where(
AssetCatalog.make == brand,
AssetCatalog.model == model,
AssetCatalog.vehicle_class == self.category
)
if gen:
stmt = stmt.where(AssetCatalog.generation == gen)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def log_entry(self, db: AsyncSession, brand: str, model: str, specs: dict = None):
"""Létrehoz vagy frissít egy katalógus bejegyzést."""
existing = await self.check_exists(db, brand, model)
async def log_entry(self, db: AsyncSession, brand: str, model: str, specs: dict):
"""Létrehoz vagy frissít egy bejegyzést az AssetCatalog-ban."""
existing = await self.check_exists(db, brand, model, specs.get("generation"))
if not existing:
new_v = VehicleCatalog(
brand=brand,
new_v = AssetCatalog(
make=brand,
model=model,
category=self.category,
factory_specs=specs or {},
verification_status="incomplete" if not specs else "verified"
generation=specs.get("generation"),
year_from=specs.get("year_from"),
year_to=specs.get("year_to"),
vehicle_class=self.category,
fuel_type=specs.get("fuel_type"),
engine_code=specs.get("engine_code")
)
db.add(new_v)
logger.info(f"🆕 Új katalógus elem: {brand} {model}")
return True
return False

View File

@@ -0,0 +1,51 @@
import asyncio
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.asset import Asset, AssetCatalog, AssetTelemetry
logger = logging.getLogger(__name__)
async def run_vehicle_recon(db: AsyncSession, asset_id: str):
"""
VIN alapján megkeresi a mélységi adatokat és frissíti a Digitális Ikert.
"""
# 1. Lekérjük a járművet és a katalógusát
stmt = select(Asset).where(Asset.id == asset_id)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset or not asset.catalog_id:
return False
logger.info(f"🤖 Robot indul: {asset.vin} felderítése...")
# 2. SZIMULÁLT ADATGYŰJTÉS (Itt hívnánk meg az API-kat: NHTSA, autodna stb.)
await asyncio.sleep(2) # Időigényes keresés szimulálása
deep_data = {
"assembly_plant": "Fremont, California",
"drive_unit": "Dual Motor - Raven type",
"onboard_charger": "11 kW",
"supercharging_max": "250 kW",
"safety_rating": "5-star EuroNCAP"
}
# 3. Katalógus frissítése
catalog_stmt = select(AssetCatalog).where(AssetCatalog.id == asset.catalog_id)
catalog = (await db.execute(catalog_stmt)).scalar_one_or_none()
if catalog:
current_data = catalog.factory_data or {}
current_data.update(deep_data)
catalog.factory_data = current_data
# 4. Telemetria frissítése (A robot talált egy visszahívást, VQI csökken kicsit)
telemetry_stmt = select(AssetTelemetry).where(AssetTelemetry.asset_id == asset_id)
telemetry = (await db.execute(telemetry_stmt)).scalar_one_or_none()
if telemetry:
telemetry.vqi_score = 99.2 # Robot frissített állapota
await db.commit()
logger.info(f"✨ Robot végzett: {asset.license_plate} felokosítva.")
return True

View File

@@ -1,40 +1,37 @@
# /app/services/robot_manager.py
import asyncio
import logging
from datetime import datetime
# Frissített importok az új fájlnevekhez:
from .harvester_cars import CarHarvester
from .harvester_bikes import BikeHarvester
from .harvester_trucks import TruckHarvester
# Megjegyzés: Ellenőrizd, hogy a harvester_bikes/trucks fájlokban is BaseHarvester az alap!
logger = logging.getLogger(__name__)
class RobotManager:
@staticmethod
async def run_full_sync(db):
"""Sorban lefuttatja az összes robotot."""
print(f"🕒 Szinkronizáció indítva: {datetime.now()}")
"""Sorban lefuttatja a robotokat az új AssetCatalog struktúrához."""
logger.info(f"🕒 Teljes szinkronizáció indítva: {datetime.now()}")
robots = [
CarHarvester(),
BikeHarvester(),
TruckHarvester()
# BikeHarvester(),
# TruckHarvester()
]
for robot in robots:
try:
# Itt a robot lekéri az API-tól az ÖSSZES márkát és frissít
await robot.run(db)
logger.info(f"{robot.category} robot sikeresen lefutott.")
await asyncio.sleep(5)
except Exception as e:
print(f"Hiba a {robot.category} robotnál: {e}")
logger.error(f"Kritikus hiba a {robot.category} robotnál: {e}")
@staticmethod
async def schedule_nightly_run(db):
"""
Egyszerű ciklus, ami figyeli az időt.
Ha éjjel 2 óra van, elindítja a teljes szinkront.
"""
while True:
now = datetime.now()
# Ha hajnali 2 és 2:01 között vagyunk, indítás
if now.hour == 2 and now.minute == 0:
await RobotManager.run_full_sync(db)
await asyncio.sleep(70) # Várunk, hogy ne induljon el többször ugyanabban a percben
await asyncio.sleep(30) # 30 másodpercenként ellenőrizzük az időt
await asyncio.sleep(70)
await asyncio.sleep(30)