feat: implement pivot-currency model, rbac smart tokens & fix circular imports
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -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!"}
|
||||
Reference in New Issue
Block a user