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

@@ -1,136 +1,129 @@
import uuid
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
import os
import logging
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.db.session import get_db
from app.api.deps import get_current_user
from app.schemas.asset import AssetCreate, AssetResponse
from app.models.asset import Asset, AssetCatalog, AssetAssignment, AssetEvent
from app.models.asset import Asset, AssetCost, AssetTelemetry
from app.models.identity import User
from app.models.organization import Organization, OrganizationMember, OrgType
from app.core.config import settings
# VIN Validator - Standard 17 karakter, tiltott karakterek (I, O, Q) szűrése
class VINValidator:
@staticmethod
def validate(vin: str) -> bool:
vin = vin.upper()
if len(vin) != 17:
return False
if any(c in vin for c in "IOQ"):
return False
return True
from app.services.cost_service import cost_service
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
async def create_asset(
asset_in: AssetCreate,
target_org_id: int = None,
# --- 1. MODUL: IDENTITÁS (Alapadatok) ---
@router.get("/{asset_id}", response_model=Dict[str, Any])
async def get_asset_identity(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# 1. VIN Validáció
if not VINValidator.validate(asset_in.vin):
raise HTTPException(status_code=400, detail="Érvénytelen alvázszám (VIN) formátum!")
# 2. Célflotta ellenőrzése
if not target_org_id:
stmt_org = select(Organization).join(OrganizationMember).where(
and_(
OrganizationMember.user_id == current_user.id,
Organization.org_type == OrgType.individual
)
)
org = (await db.execute(stmt_org)).scalar_one_or_none()
if not org:
raise HTTPException(status_code=404, detail="Privát flotta nem található. KYC szükséges.")
final_org_id = org.id
else:
# Céges jogosultság ellenőrzése
stmt_mem = select(OrganizationMember).where(
and_(
OrganizationMember.organization_id == target_org_id,
OrganizationMember.user_id == current_user.id
)
)
member = (await db.execute(stmt_mem)).scalar_one_or_none()
if not member or (member.role != "owner" and not (member.permissions or {}).get("can_add_asset")):
raise HTTPException(status_code=403, detail="Nincs jogod ehhez a flottához!")
final_org_id = target_org_id
# 3. Katalógus ellenőrzése
stmt_cat = select(AssetCatalog).where(
and_(
AssetCatalog.make.ilike(asset_in.make), # Simán ilike, nem kell func() köré
AssetCatalog.model.ilike(asset_in.model)
)
)
catalog_item = (await db.execute(stmt_cat)).scalar_one_or_none()
"""Csak a jármű alapadatai és katalógus információi."""
stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.catalog))
asset = (await db.execute(stmt)).scalar_one_or_none()
if not catalog_item:
catalog_item = AssetCatalog(
make=asset_in.make,
model=asset_in.model,
vehicle_class=asset_in.vehicle_class,
fuel_type=asset_in.fuel_type
)
db.add(catalog_item)
await db.flush()
if not asset:
raise HTTPException(status_code=404, detail="Jármű nem található")
return {
"id": asset.id,
"vin": asset.vin,
"license_plate": asset.license_plate,
"name": asset.name,
"catalog": {
"make": asset.catalog.make,
"model": asset.catalog.model,
"type": asset.catalog.vehicle_class,
"factory_data": getattr(asset.catalog, 'factory_data', {})
}
}
# 4. Asset létrehozása vagy betöltése (Shadow Identity)
stmt_exist = select(Asset).where(Asset.vin == asset_in.vin.upper())
new_asset = (await db.execute(stmt_exist)).scalar_one_or_none()
if not new_asset:
new_asset = Asset(
vin=asset_in.vin.upper(),
license_plate=asset_in.license_plate,
name=asset_in.name or f"{asset_in.make} {asset_in.model}",
year_of_manufacture=asset_in.year_of_manufacture,
fuel_type=asset_in.fuel_type, # JAVÍTVA: Most már átadjuk
vehicle_class=asset_in.vehicle_class, # JAVÍTVA: Most már átadjuk
mileage_unit=asset_in.reading_unit, # JAVÍTVA: Most már átadjuk
catalog_id=catalog_item.id,
quality_index=1.00,
system_mileage=0
)
db.add(new_asset)
await db.flush()
# 5. Assignment
new_assignment = AssetAssignment(
asset_id=new_asset.id,
organization_id=final_org_id,
status="active"
)
db.add(new_assignment)
# 6. Kezdő KM esemény
if asset_in.current_reading:
db.add(AssetEvent(
asset_id=new_asset.id,
event_type="initial_reading",
recorded_mileage=asset_in.current_reading,
description="Kezdeti óraállás rögzítése",
data={"source": "user_registration"}
))
# --- 2. MODUL: PÉNZÜGY (Költségek) ---
@router.get("/{asset_id}/costs", response_model=Dict[str, Any])
async def get_asset_costs(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Pénzügyi modul: Helyi és EUR alapú összesítő, tételes lista."""
stmt = select(AssetCost).where(AssetCost.asset_id == asset_id)
costs = (await db.execute(stmt)).scalars().all()
summary_local = {}
summary_eur = {}
history = []
for c in costs:
cat = c.cost_type or "OTHER"
amt_local = float(c.amount_local)
amt_eur = float(c.amount_eur) if c.amount_eur else 0.0
summary_local[cat] = summary_local.get(cat, 0) + amt_local
summary_eur[cat] = summary_eur.get(cat, 0) + amt_eur
history.append({
"id": c.id,
"category": cat,
"amount_local": amt_local,
"currency_local": c.currency_local,
"amount_eur": amt_eur,
"exchange_rate": float(c.exchange_rate_used) if c.exchange_rate_used else 1.0,
"date": c.date
})
return {
"total_gross_local": sum(summary_local.values()),
"total_gross_eur": sum(summary_eur.values()),
"summary_local": summary_local,
"summary_eur": summary_eur,
"history": history
}
@router.post("/{asset_id}/costs", response_model=AssetCostResponse, status_code=status.HTTP_201_CREATED)
async def create_asset_cost(
asset_id: uuid.UUID,
cost_in: AssetCostCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Új költség rögzítése.
Automatikus: EUR konverzió, Telemetria frissítés, XP jóváírás.
"""
# Validáció: az asset_id-nak egyeznie kell a path-szal
if cost_in.asset_id != asset_id:
raise HTTPException(status_code=400, detail="Asset ID mismatch")
try:
await db.commit()
await db.refresh(new_asset)
# 7. NAS mappa struktúra
nas_base = getattr(settings, "NAS_STORAGE_PATH", "/opt/docker/dev/service_finder/nas/assets")
asset_path = os.path.join(nas_base, str(new_asset.id))
os.makedirs(os.path.join(asset_path, "docs"), exist_ok=True)
os.makedirs(os.path.join(asset_path, "photos"), exist_ok=True)
return new_asset
new_cost = await cost_service.record_cost(
db=db,
cost_in=cost_in,
user_id=current_user.id
)
return new_cost
except Exception as e:
await db.rollback()
logger.error(f"Asset Creation Error: {str(e)}")
raise HTTPException(status_code=500, detail="Hiba a mentés során.")
raise HTTPException(status_code=500, detail=str(e))
# --- 3. MODUL: TELEMETRIA (Állapot) ---
@router.get("/{asset_id}/telemetry", response_model=Dict[str, Any])
async def get_asset_telemetry(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Műszaki állapot: KM óra, VQI (Quality) és DBS (Driving) pontszámok."""
stmt = select(AssetTelemetry).where(AssetTelemetry.asset_id == asset_id)
tel = (await db.execute(stmt)).scalar_one_or_none()
if not tel:
return {"current_mileage": 0, "vqi_score": 100.0, "dbs_score": 100.0}
return {
"current_mileage": tel.current_mileage,
"vqi_score": float(tel.vqi_score),
"dbs_score": float(tel.dbs_score),
"last_update": tel.updated_at if hasattr(tel, 'updated_at') else None
}

View File

@@ -5,7 +5,7 @@ from sqlalchemy import select
from app.db.session import get_db
from app.services.auth_service import AuthService
from app.core.security import create_access_token
from app.core.security import create_access_token, RANK_MAP
from app.schemas.auth import (
UserLiteRegister, Token, PasswordResetRequest,
UserKYCComplete, PasswordResetConfirm
@@ -17,7 +17,7 @@ router = APIRouter()
@router.post("/register-lite", response_model=Token, status_code=status.HTTP_201_CREATED)
async def register_lite(user_in: UserLiteRegister, db: AsyncSession = Depends(get_db)):
"""Step 1: Alapszintű regisztráció (Email + Jelszó)."""
"""Step 1: Alapszintű regisztráció. Az új felhasználó alapértelmezetten 'user' (Rank 10)."""
stmt = select(User).where(User.email == user_in.email)
result = await db.execute(stmt)
if result.scalar_one_or_none():
@@ -28,7 +28,17 @@ async def register_lite(user_in: UserLiteRegister, db: AsyncSession = Depends(ge
try:
user = await AuthService.register_lite(db, user_in)
token = create_access_token(data={"sub": str(user.id)})
# Kezdeti token generálása
token_data = {
"sub": str(user.id),
"role": "user",
"rank": 10,
"scope_level": "individual",
"scope_id": str(user.id)
}
token = create_access_token(data=token_data)
return {
"access_token": token,
"token_type": "bearer",
@@ -45,7 +55,7 @@ async def login(
db: AsyncSession = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends()
):
"""Bejelentkezés és Access Token generálása."""
"""Bejelentkezés és okos JWT generálása RBAC adatokkal."""
user = await AuthService.authenticate(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
@@ -53,7 +63,20 @@ async def login(
detail="Hibás e-mail cím vagy jelszó."
)
token = create_access_token(data={"sub": str(user.id)})
# Szerepkör string kinyerése és rang meghatározása a RANK_MAP-ből
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
user_rank = RANK_MAP.get(role_name, 10)
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": user_rank,
"scope_level": user.scope_level or "individual",
"scope_id": user.scope_id or str(user.id),
"region": user.region_code
}
token = create_access_token(data=token_data)
return {
"access_token": token,
"token_type": "bearer",
@@ -62,14 +85,11 @@ async def login(
@router.get("/verify-email")
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
"""E-mail megerősítése a kiküldött link alapján."""
"""E-mail megerősítése."""
success = await AuthService.verify_email(db, token)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Érvénytelen vagy lejárt megerősítő token."
)
return {"message": "Email sikeresen megerősítve! Jöhet a profil kitöltése (KYC)."}
raise HTTPException(status_code=400, detail="Érvénytelen vagy lejárt token.")
return {"message": "Email sikeresen megerősítve!"}
@router.post("/complete-kyc")
async def complete_kyc(
@@ -77,38 +97,27 @@ async def complete_kyc(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Step 2: Személyes adatok és okmányok rögzítése."""
"""Step 2: KYC adatok rögzítése és aktiválás."""
user = await AuthService.complete_kyc(db, current_user.id, kyc_in)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Felhasználó nem található.")
return {"status": "success", "message": "A profil adatok rögzítve, a rendszer aktiválva."}
raise HTTPException(status_code=404, detail="Felhasználó nem található.")
return {"status": "success", "message": "A profil aktiválva."}
@router.post("/forgot-password")
async def forgot_password(req: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
"""Elfelejtett jelszó folyamat indítása biztonsági korlátokkal."""
"""Elfelejtett jelszó folyamat."""
result = await AuthService.initiate_password_reset(db, req.email)
if result == "cooldown":
raise HTTPException(status_code=429, detail="Kérjük várjon 2 percet az újabb kérés előtt.")
if result in ["hourly_limit", "daily_limit"]:
raise HTTPException(status_code=429, detail="Túllépte a napi/óránkénti próbálkozások számát.")
return {"message": "Amennyiben a megadott e-mail cím szerepel a rendszerünkben, kiküldtük a linket."}
raise HTTPException(status_code=429, detail="Kérjük várjon 2 percet.")
return {"message": "Amennyiben a cím létezik, a linket kiküldtük."}
@router.post("/reset-password")
async def reset_password(req: PasswordResetConfirm, db: AsyncSession = Depends(get_db)):
"""Új jelszó beállítása. Backend ellenőrzi az egyezőséget és a tokent."""
"""Új jelszó beállítása."""
if req.password != req.password_confirm:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A két jelszó nem egyezik meg."
)
raise HTTPException(status_code=400, detail="A jelszavak nem egyeznek.")
success = await AuthService.reset_password(db, req.email, req.token, req.password)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Érvénytelen adatok vagy lejárt token."
)
return {"message": "A jelszó sikeresen frissítve! Most már bejelentkezhet."}
raise HTTPException(status_code=400, detail="Hiba a jelszó frissítésekor.")
return {"message": "A jelszó sikeresen frissítve!"}