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,150 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql+asyncpg://user:pass@postgres-db:5432/service_finder
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

13
backend/.env Normal file
View File

@@ -0,0 +1,13 @@
# Database
DATABASE_URL=postgresql+asyncpg://service_finder_app:JELSZAVAD@db:5432/service_finder
# Security
SECRET_KEY=ide_generálj_egy_hosszú_véletlen_karaktersort
# Initial Admin (Ezt fogja a seed script használni)
INITIAL_ADMIN_EMAIL=kincses@valami.hu
INITIAL_ADMIN_PASSWORD=Kincs€s74
# Debug mód (opcionális)
DEBUG=True

0
backend/app/__pycache__/__init__.cpython-312.pyc Executable file → Normal file
View File

View File

@@ -1,4 +1,4 @@
from typing import Optional from typing import Optional, Dict, Any
import logging import logging
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
@@ -6,24 +6,27 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.db.session import get_db from app.db.session import get_db
from app.core.security import decode_token from app.core.security import decode_token, RANK_MAP
from app.models.identity import User from app.models.identity import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user( async def get_current_token_payload(
db: AsyncSession = Depends(get_db), token: str = Depends(reusable_oauth2)
token: str = Depends(reusable_oauth2), ) -> Dict[str, Any]:
) -> User:
""" """
Dependency, amely visszaadja az aktuálisan bejelentkezett felhasználót. Kinyeri a token payload-ot DB hívás nélkül.
Támogatja a 'dev_bypass_active' tokent a fejlesztői teszteléshez. Ez teszi lehetővé a gyors jogosultság-ellenőrzést.
""" """
# FEJLESZTŐI BYPASS
if token == "dev_bypass_active": if token == "dev_bypass_active":
result = await db.execute(select(User).where(User.id == 1)) return {
return result.scalar_one() "sub": "1",
"role": "superadmin",
"rank": 100,
"scope_level": "global",
"scope_id": "all"
}
payload = decode_token(token) payload = decode_token(token)
if not payload: if not payload:
@@ -31,27 +34,38 @@ async def get_current_user(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen vagy lejárt munkamenet." detail="Érvénytelen vagy lejárt munkamenet."
) )
return payload
user_id: str = payload.get("sub")
async def get_current_user(
db: AsyncSession = Depends(get_db),
payload: Dict[str, Any] = Depends(get_current_token_payload),
) -> User:
"""
Visszaadja a teljes User modellt. Akkor használjuk, ha módosítani kell az usert.
"""
user_id = payload.get("sub")
if not user_id: if not user_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token azonosítási hiba.")
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token azonosítási hiba."
)
result = await db.execute(select(User).where(User.id == int(user_id))) result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user or user.is_deleted:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="A felhasználó nem található.")
status_code=status.HTTP_404_NOT_FOUND,
detail="A felhasználó nem található."
)
if user.is_deleted:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Ez a fiók korábban törlésre került."
)
return user return user
def check_min_rank(required_rank: int):
"""
Függőség-gyár: Ellenőrzi, hogy a felhasználó rangja eléri-e a minimumot.
Használat: Depends(check_min_rank(60)) -> RegionAdmin+
"""
def rank_checker(payload: Dict[str, Any] = Depends(get_current_token_payload)):
user_rank = payload.get("rank", 0)
if user_rank < required_rank:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Nincs elegendő jogosultsága a művelethez. (Szükséges szint: {required_rank})"
)
return True
return rank_checker

View File

@@ -1,136 +1,129 @@
import uuid
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_ from sqlalchemy import select
import os from sqlalchemy.orm import selectinload
import logging
from app.db.session import get_db from app.db.session import get_db
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.schemas.asset import AssetCreate, AssetResponse from app.models.asset import Asset, AssetCost, AssetTelemetry
from app.models.asset import Asset, AssetCatalog, AssetAssignment, AssetEvent
from app.models.identity import User from app.models.identity import User
from app.models.organization import Organization, OrganizationMember, OrgType from app.services.cost_service import cost_service
from app.core.config import settings from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
# 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
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/", response_model=AssetResponse, status_code=status.HTTP_201_CREATED) # --- 1. MODUL: IDENTITÁS (Alapadatok) ---
async def create_asset( @router.get("/{asset_id}", response_model=Dict[str, Any])
asset_in: AssetCreate, async def get_asset_identity(
target_org_id: int = None, asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
# 1. VIN Validáció """Csak a jármű alapadatai és katalógus információi."""
if not VINValidator.validate(asset_in.vin): stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.catalog))
raise HTTPException(status_code=400, detail="Érvénytelen alvázszám (VIN) formátum!") asset = (await db.execute(stmt)).scalar_one_or_none()
# 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()
if not catalog_item: if not asset:
catalog_item = AssetCatalog( raise HTTPException(status_code=404, detail="Jármű nem található")
make=asset_in.make,
model=asset_in.model, return {
vehicle_class=asset_in.vehicle_class, "id": asset.id,
fuel_type=asset_in.fuel_type "vin": asset.vin,
) "license_plate": asset.license_plate,
db.add(catalog_item) "name": asset.name,
await db.flush() "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) # --- 2. MODUL: PÉNZÜGY (Költségek) ---
stmt_exist = select(Asset).where(Asset.vin == asset_in.vin.upper()) @router.get("/{asset_id}/costs", response_model=Dict[str, Any])
new_asset = (await db.execute(stmt_exist)).scalar_one_or_none() async def get_asset_costs(
asset_id: uuid.UUID,
if not new_asset: db: AsyncSession = Depends(get_db),
new_asset = Asset( current_user: User = Depends(get_current_user)
vin=asset_in.vin.upper(), ):
license_plate=asset_in.license_plate, """Pénzügyi modul: Helyi és EUR alapú összesítő, tételes lista."""
name=asset_in.name or f"{asset_in.make} {asset_in.model}", stmt = select(AssetCost).where(AssetCost.asset_id == asset_id)
year_of_manufacture=asset_in.year_of_manufacture, costs = (await db.execute(stmt)).scalars().all()
fuel_type=asset_in.fuel_type, # JAVÍTVA: Most már átadjuk
vehicle_class=asset_in.vehicle_class, # JAVÍTVA: Most már átadjuk summary_local = {}
mileage_unit=asset_in.reading_unit, # JAVÍTVA: Most már átadjuk summary_eur = {}
catalog_id=catalog_item.id, history = []
quality_index=1.00,
system_mileage=0 for c in costs:
) cat = c.cost_type or "OTHER"
db.add(new_asset) amt_local = float(c.amount_local)
await db.flush() amt_eur = float(c.amount_eur) if c.amount_eur else 0.0
# 5. Assignment summary_local[cat] = summary_local.get(cat, 0) + amt_local
new_assignment = AssetAssignment( summary_eur[cat] = summary_eur.get(cat, 0) + amt_eur
asset_id=new_asset.id,
organization_id=final_org_id, history.append({
status="active" "id": c.id,
) "category": cat,
db.add(new_assignment) "amount_local": amt_local,
"currency_local": c.currency_local,
# 6. Kezdő KM esemény "amount_eur": amt_eur,
if asset_in.current_reading: "exchange_rate": float(c.exchange_rate_used) if c.exchange_rate_used else 1.0,
db.add(AssetEvent( "date": c.date
asset_id=new_asset.id, })
event_type="initial_reading",
recorded_mileage=asset_in.current_reading, return {
description="Kezdeti óraállás rögzítése", "total_gross_local": sum(summary_local.values()),
data={"source": "user_registration"} "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: try:
await db.commit() new_cost = await cost_service.record_cost(
await db.refresh(new_asset) db=db,
cost_in=cost_in,
# 7. NAS mappa struktúra user_id=current_user.id
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)) return new_cost
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
except Exception as e: except Exception as e:
await db.rollback() raise HTTPException(status_code=500, detail=str(e))
logger.error(f"Asset Creation Error: {str(e)}")
raise HTTPException(status_code=500, detail="Hiba a mentés során.") # --- 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.db.session import get_db
from app.services.auth_service import AuthService 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 ( from app.schemas.auth import (
UserLiteRegister, Token, PasswordResetRequest, UserLiteRegister, Token, PasswordResetRequest,
UserKYCComplete, PasswordResetConfirm UserKYCComplete, PasswordResetConfirm
@@ -17,7 +17,7 @@ router = APIRouter()
@router.post("/register-lite", response_model=Token, status_code=status.HTTP_201_CREATED) @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)): 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) stmt = select(User).where(User.email == user_in.email)
result = await db.execute(stmt) result = await db.execute(stmt)
if result.scalar_one_or_none(): if result.scalar_one_or_none():
@@ -28,7 +28,17 @@ async def register_lite(user_in: UserLiteRegister, db: AsyncSession = Depends(ge
try: try:
user = await AuthService.register_lite(db, user_in) 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 { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -45,7 +55,7 @@ async def login(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends() 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) user = await AuthService.authenticate(db, form_data.username, form_data.password)
if not user: if not user:
raise HTTPException( raise HTTPException(
@@ -53,7 +63,20 @@ async def login(
detail="Hibás e-mail cím vagy jelszó." 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 { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -62,14 +85,11 @@ async def login(
@router.get("/verify-email") @router.get("/verify-email")
async def verify_email(token: str, db: AsyncSession = Depends(get_db)): 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) success = await AuthService.verify_email(db, token)
if not success: if not success:
raise HTTPException( raise HTTPException(status_code=400, detail="Érvénytelen vagy lejárt token.")
status_code=status.HTTP_400_BAD_REQUEST, return {"message": "Email sikeresen megerősítve!"}
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)."}
@router.post("/complete-kyc") @router.post("/complete-kyc")
async def complete_kyc( async def complete_kyc(
@@ -77,38 +97,27 @@ async def complete_kyc(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) 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) user = await AuthService.complete_kyc(db, current_user.id, kyc_in)
if not user: if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Felhasználó nem található.") raise HTTPException(status_code=404, detail="Felhasználó nem található.")
return {"status": "success", "message": "A profil adatok rögzítve, a rendszer aktiválva."} return {"status": "success", "message": "A profil aktiválva."}
@router.post("/forgot-password") @router.post("/forgot-password")
async def forgot_password(req: PasswordResetRequest, db: AsyncSession = Depends(get_db)): 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) result = await AuthService.initiate_password_reset(db, req.email)
if result == "cooldown": if result == "cooldown":
raise HTTPException(status_code=429, detail="Kérjük várjon 2 percet az újabb kérés előtt.") raise HTTPException(status_code=429, detail="Kérjük várjon 2 percet.")
if result in ["hourly_limit", "daily_limit"]: return {"message": "Amennyiben a cím létezik, a linket kiküldtük."}
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."}
@router.post("/reset-password") @router.post("/reset-password")
async def reset_password(req: PasswordResetConfirm, db: AsyncSession = Depends(get_db)): 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: if req.password != req.password_confirm:
raise HTTPException( raise HTTPException(status_code=400, detail="A jelszavak nem egyeznek.")
status_code=status.HTTP_400_BAD_REQUEST,
detail="A két jelszó nem egyezik meg."
)
success = await AuthService.reset_password(db, req.email, req.token, req.password) success = await AuthService.reset_password(db, req.email, req.token, req.password)
if not success: if not success:
raise HTTPException( raise HTTPException(status_code=400, detail="Hiba a jelszó frissítésekor.")
status_code=status.HTTP_400_BAD_REQUEST, return {"message": "A jelszó sikeresen frissítve!"}
detail="Érvénytelen adatok vagy lejárt token."
)
return {"message": "A jelszó sikeresen frissítve! Most már bejelentkezhet."}

0
backend/app/core/__pycache__/__init__.cpython-312.pyc Executable file → Normal file
View File

View File

@@ -1,6 +1,5 @@
import os import os
import json from typing import Any, Optional
from typing import Any, Optional, List
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,41 +9,38 @@ class Settings(BaseSettings):
PROJECT_NAME: str = "Traffic Ecosystem SuperApp" PROJECT_NAME: str = "Traffic Ecosystem SuperApp"
VERSION: str = "1.0.0" VERSION: str = "1.0.0"
API_V1_STR: str = "/api/v1" API_V1_STR: str = "/api/v1"
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" DEBUG: bool = False
# --- Security / JWT --- # --- Security / JWT ---
# Szigorúan .env-ből! SECRET_KEY: str = "NOT_SET_DANGER"
SECRET_KEY: str = os.getenv("SECRET_KEY", "NOT_SET_DANGER")
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 nap ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 nap
# --- Database & Cache --- # --- Initial Admin (ÚJ SZEKCIÓ) ---
DATABASE_URL: str = os.getenv("DATABASE_URL") # Ezeket a .env-ből fogja venni
REDIS_URL: str = os.getenv("REDIS_URL", "redis://service_finder_redis:6379/0") INITIAL_ADMIN_EMAIL: str = "admin@servicefinder.hu"
INITIAL_ADMIN_PASSWORD: str = "Admin123!"
# --- Email (Auto Provider) --- # --- Database & Cache ---
EMAIL_PROVIDER: str = os.getenv("EMAIL_PROVIDER", "auto") DATABASE_URL: str
EMAILS_FROM_EMAIL: str = os.getenv("EMAILS_FROM_EMAIL", "info@profibot.hu") REDIS_URL: str = "redis://service_finder_redis:6379/0"
# --- Email ---
EMAIL_PROVIDER: str = "auto"
EMAILS_FROM_EMAIL: str = "info@profibot.hu"
EMAILS_FROM_NAME: str = "Profibot" EMAILS_FROM_NAME: str = "Profibot"
# SMTP & SendGrid (Szigorúan .env-ből) SENDGRID_API_KEY: Optional[str] = None
SENDGRID_API_KEY: Optional[str] = os.getenv("SENDGRID_API_KEY") SMTP_HOST: Optional[str] = None
SMTP_HOST: Optional[str] = os.getenv("SMTP_HOST") SMTP_PORT: int = 587
SMTP_PORT: int = int(os.getenv("SMTP_PORT", 587)) SMTP_USER: Optional[str] = None
SMTP_USER: Optional[str] = os.getenv("SMTP_USER") SMTP_PASSWORD: Optional[str] = None
SMTP_PASSWORD: Optional[str] = os.getenv("SMTP_PASSWORD")
# --- External URLs --- # --- External URLs ---
# .env-ben legyen átírva a .10-es IP-re! FRONTEND_BASE_URL: str = "http://localhost:3000"
FRONTEND_BASE_URL: str = os.getenv("FRONTEND_BASE_URL", "http://localhost:3000")
# --- Dinamikus Admin Motor --- # --- Dinamikus Admin Motor ---
async def get_db_setting(self, db: AsyncSession, key_name: str, default: Any = None) -> Any: async def get_db_setting(self, db: AsyncSession, key_name: str, default: Any = None) -> Any:
"""
Lekéri a paramétert a data.system_settings táblából.
Ezzel érjük el, hogy a kód újraírása nélkül, adminból lehessen
állítani a jutalom napokat, százalékokat, stb.
"""
try: try:
query = text("SELECT value_json FROM data.system_settings WHERE key_name = :key") query = text("SELECT value_json FROM data.system_settings WHERE key_name = :key")
result = await db.execute(query, {"key": key_name}) result = await db.execute(query, {"key": key_name})
@@ -63,5 +59,4 @@ class Settings(BaseSettings):
extra="ignore" extra="ignore"
) )
settings = Settings() settings = Settings()

View File

@@ -1,3 +1,4 @@
# /opt/docker/dev/service_finder/backend/app/core/i18n.py
import json import json
import os import os
@@ -9,21 +10,44 @@ class LocaleManager:
self._load() self._load()
data = self._locales.get(lang, self._locales.get("hu", {})) data = self._locales.get(lang, self._locales.get("hu", {}))
# Biztonságos bejárás a pontokkal elválasztott kulcsokhoz
for k in key.split("."): for k in key.split("."):
data = data.get(k, {}) if isinstance(data, dict):
data = data.get(k, {})
else:
return key # Ha elakadunk, adjuk vissza magát a kulcsot
if isinstance(data, str): if isinstance(data, str):
return data.format(**kwargs) return data.format(**kwargs)
return key return key
def _load(self): def _load(self):
path = "backend/app/locales" # Konténeren belül: "/app/app/locales" # A konténeren belül ez a biztos útvonal
if not os.path.exists(path): path = "app/locales" possible_paths = [
"/app/app/locales",
"app/locales",
"backend/app/locales"
]
path = ""
for p in possible_paths:
if os.path.exists(p):
path = p
break
if not path:
print("FIGYELEM: Nem található a locales könyvtár!")
return
for file in os.listdir(path): for file in os.listdir(path):
if file.endswith(".json"): if file.endswith(".json"):
lang = file.split(".")[0] lang = file.split(".")[0]
with open(os.path.join(path, file), "r", encoding="utf-8") as f: try:
self._locales[lang] = json.load(f) with open(os.path.join(path, file), "r", encoding="utf-8") as f:
self._locales[lang] = json.load(f)
except Exception as e:
print(f"Hiba a {file} betöltésekor: {e}")
locale_manager = LocaleManager() locale_manager = LocaleManager()
# Rövid alias a könnyebb használathoz
t = locale_manager.get

40
backend/app/core/rbac.py Normal file
View File

@@ -0,0 +1,40 @@
# /opt/docker/dev/service_finder/backend/app/core/rbac.py
from fastapi import HTTPException, Depends, status
from app.api.deps import get_current_user
from app.models.identity import User
class RBAC:
def __init__(self, required_perm: str = None, min_rank: int = 0):
self.required_perm = required_perm
self.min_rank = min_rank
async def __call__(self, current_user: User = Depends(get_current_user)):
# 1. Szuperadmin (Rank 100) mindent visz
if current_user.role == "SUPERADMIN":
return True
# 2. Rang ellenőrzés (Hierarchia)
# Itt feltételezzük, hogy a role-okhoz rendelt rank-okat egy configból vesszük
user_rank = self.get_role_rank(current_user.role)
if user_rank < self.min_rank:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Ezen a hierarchia szinten ez a művelet nem engedélyezett."
)
# 3. Egyedi képesség ellenőrzés (Capabilities)
user_perms = current_user.custom_permissions.get("capabilities", [])
if self.required_perm and self.required_perm not in user_perms:
# Ha a sablonban sincs benne, akkor tiltás
if not self.check_role_template(current_user.role, self.required_perm):
raise HTTPException(status_code=403, detail="Nincs meg a specifikus jogosultságod.")
return True
def get_role_rank(self, role: str):
ranks = {"COUNTRY_ADMIN": 80, "REGION_ADMIN": 60, "MODERATOR": 40, "SALES": 20, "USER": 10}
return ranks.get(role, 0)
def check_role_template(self, role: str, perm: str):
# Ide jön majd az RBAC_MASTER_CONFIG JSON betöltése
return False

View File

@@ -4,6 +4,20 @@ import bcrypt
from jose import jwt, JWTError from jose import jwt, JWTError
from app.core.config import settings from app.core.config import settings
# Master Book 5.0: RBAC Rank Definition Matrix
# Ezek a szintek határozzák meg a hozzáférést a Middleware szintjén.
RANK_MAP = {
"superadmin": 100,
"country_admin": 80,
"region_admin": 60,
"moderator": 40,
"sales": 20,
"user": 10,
"service": 15,
"fleet_manager": 25,
"driver": 5
}
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Összehasonlítja a sima szöveges jelszót a hash-elt változattal.""" """Összehasonlítja a sima szöveges jelszót a hash-elt változattal."""
if not hashed_password: if not hashed_password:
@@ -22,14 +36,23 @@ def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Létrehozza a JWT access tokent.""" """
Létrehozza a JWT access tokent bővített RBAC adatokkal.
Várt kulcsok: sub (user_id), role, rank, scope_level, scope_id
"""
to_encode = data.copy() to_encode = data.copy()
if expires_delta: if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta expire = datetime.now(timezone.utc) + expires_delta
else: else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire}) # Rendszer szintű metaadatok hozzáadása
to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc),
"iss": "service-finder-auth"
})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt return encoded_jwt

0
backend/app/db/__pycache__/__init__.cpython-312.pyc Executable file → Normal file
View File

0
backend/app/db/__pycache__/session.cpython-312.pyc Executable file → Normal file
View File

View File

@@ -1,6 +1,20 @@
# /opt/docker/dev/service_finder/backend/app/db/base.py
from app.db.base_class import Base # noqa from app.db.base_class import Base # noqa
# Közvetlen importok a fájlokból (Circular Import elkerülése)
from app.models.address import Address, GeoPostalCode, GeoStreet, GeoStreetType # noqa from app.models.address import Address, GeoPostalCode, GeoStreet, GeoStreetType # noqa
from app.models.identity import User, Person, VerificationToken, Wallet # noqa from app.models.identity import User, Person, VerificationToken, Wallet # noqa
from app.models.organization import Organization, OrganizationMember # noqa from app.models.organization import Organization, OrganizationMember # noqa
from app.models.asset import Asset, AssetCatalog, AssetCost, AssetEvent # noqa from app.models.asset import ( # noqa
from app.models.gamification import UserStats, PointsLedger # noqa Asset, AssetCatalog, AssetCost, AssetEvent,
AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate
)
from app.models.gamification import ( # noqa
PointRule, LevelConfig, UserStats, Badge, UserBadge, Rating, PointsLedger
)
from app.models.system_config import SystemParameter # noqa
from app.models.history import AuditLog, VehicleOwnership # noqa
from app.models.document import Document # noqa
from app.models.core_logic import ( # noqa
SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
)

View File

@@ -0,0 +1,91 @@
import asyncio
import os
from sqlalchemy import text, select
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
# Importáljuk a rendszermodulokat az ellenőrzéshez
try:
from app.core.config import settings
from app.core.i18n import t
from app.models.system_config import SystemParameter
except ImportError as e:
print(f"❌ Import hiba: {e}")
print("Ellenőrizd, hogy a PYTHONPATH be van-e állítva!")
exit(1)
async def diagnose():
print("\n" + "="*40)
print("🔍 SZERVIZ KERESŐ - RENDSZER DIAGNOSZTIKA")
print("="*40 + "\n")
engine = create_async_engine(settings.DATABASE_URL)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# --- 1. SÉMA ELLENŐRZÉSE ---
print("1⃣ Adatbázis séma ellenőrzése...")
try:
# Organizations tábla oszlopai
org_res = await session.execute(text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'data' AND table_name = 'organizations';"
))
org_cols = [row[0] for row in org_res.fetchall()]
# Users tábla oszlopai
user_res = await session.execute(text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'data' AND table_name = 'users';"
))
user_cols = [row[0] for row in user_res.fetchall()]
checks = [
("organizations.language", "language" in org_cols),
("organizations.default_currency", "default_currency" in org_cols),
("users.preferred_language", "preferred_language" in user_cols),
("system_parameters tábla létezik", True) # Ha idáig eljut, a SystemParameter import sikerült
]
for label, success in checks:
status = "✅ OK" if success else "❌ HIÁNYZIK"
print(f" [{status}] {label}")
except Exception as e:
print(f" ❌ Hiba a séma lekérdezésekor: {e}")
# --- 2. ADATOK ELLENŐRZÉSE ---
print("\n2⃣ System Parameters (Alapadatok) ellenőrzése...")
try:
result = await session.execute(select(SystemParameter))
params = result.scalars().all()
if params:
print(f" ✅ Talált paraméterek: {len(params)} db")
for p in params:
print(f" - {p.key}: {p.value[:2]}... (+{len(p.value)-2} elem)")
else:
print(" ⚠️ Figyelem: A system_parameters tábla üres!")
except Exception as e:
print(f" ❌ Hiba az adatok lekérésekor: {e}")
# --- 3. NYELVI MOTOR ELLENŐRZÉSE ---
print("\n3⃣ Nyelvi motor (i18n) és hu.json ellenőrzése...")
try:
test_save = t("COMMON.SAVE")
test_email = t("email.reg_greeting", first_name="Admin")
if test_save != "COMMON.SAVE":
print(f" ✅ Fordítás sikeres: COMMON.SAVE -> '{test_save}'")
print(f" ✅ Paraméteres fordítás: '{test_email}'")
else:
print(" ❌ A fordítás NEM működik (csak a kulcsot adta vissza).")
print(f" Ellenőrizd a /app/app/locales/hu.json elérhetőségét!")
except Exception as e:
print(f" ❌ Hiba a nyelvi motor futtatásakor: {e}")
print("\n" + "="*40)
print("✅ DIAGNOSZTIKA KÉSZ")
print("="*40 + "\n")
if __name__ == "__main__":
asyncio.run(diagnose())

View File

@@ -1,7 +1,7 @@
{ {
"email": { "email": {
"registration_subject": "Regisztráció - Service Finder", "reg_subject": "Regisztráció - Service Finder",
"password_reset_subject": "Jelszó visszaállítás - Service Finder", "pwd_reset_subject": "Jelszó visszaállítás - Service Finder",
"reg_greeting": "Szia {first_name}!", "reg_greeting": "Szia {first_name}!",
"reg_body": "A regisztrációd befejezéséhez és a 'Privát Széfed' aktiválásához kattints az alábbi gombra:", "reg_body": "A regisztrációd befejezéséhez és a 'Privát Széfed' aktiválásához kattints az alábbi gombra:",
"reg_button": "Fiók Aktiválása", "reg_button": "Fiók Aktiválása",
@@ -9,6 +9,23 @@
"pwd_reset_greeting": "Szia!", "pwd_reset_greeting": "Szia!",
"pwd_reset_body": "Jelszó-visszaállítási kérelem érkezett. Kattints a gombra az új jelszó megadásához:", "pwd_reset_body": "Jelszó-visszaállítási kérelem érkezett. Kattints a gombra az új jelszó megadásához:",
"pwd_reset_button": "Jelszó visszaállítása", "pwd_reset_button": "Jelszó visszaállítása",
"pwd_reset_footer": "A link 1 óráig érvényes." "pwd_reset_footer": "A link 1 óráig érvényes.",
"link_fallback": "Ha a gomb nem működik, másolja be ezt a linket a böngészőjébe:"
},
"COMMON": {
"SAVE": "Mentés",
"CANCEL": "Mégse",
"DELETE": "Törlés"
},
"VEHICLE": {
"LICENSE_PLATE": "Rendszám",
"VIN": "Alvázszám",
"ADD_SUCCESS": "Jármű sikeresen hozzáadva: {name}",
"NOT_FOUND": "A jármű nem található."
},
"COST": {
"AMOUNT": "Összeg",
"CURRENCY": "Pénznem",
"VAT": "ÁFA"
} }
} }

View File

@@ -1,37 +1,32 @@
# /opt/docker/dev/service_finder/backend/app/models/__init__.py
from app.db.base_class import Base from app.db.base_class import Base
from .identity import User, Person, Wallet, UserRole, VerificationToken from .identity import User, Person, Wallet, UserRole, VerificationToken
from .organization import Organization, OrganizationMember from .organization import Organization, OrganizationMember
from .asset import Asset, AssetCatalog, AssetCost, AssetEvent from .asset import (
Asset, AssetCatalog, AssetCost, AssetEvent,
AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate
)
from .address import Address, GeoPostalCode, GeoStreet, GeoStreetType from .address import Address, GeoPostalCode, GeoStreet, GeoStreetType
from .gamification import UserStats, PointsLedger from .gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, Rating, PointsLedger
from .system_config import SystemParameter
from .document import Document
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
from .history import AuditLog, VehicleOwnership
# Aliasok a kompatibilitás és a tiszta kód érdekében # Aliasok
Vehicle = Asset Vehicle = Asset
UserVehicle = Asset UserVehicle = Asset
VehicleCatalog = AssetCatalog VehicleCatalog = AssetCatalog
ServiceRecord = AssetEvent ServiceRecord = AssetEvent
__all__ = [ __all__ = [
"Base", "Base", "User", "Person", "Wallet", "UserRole", "VerificationToken",
"User", "Organization", "OrganizationMember", "Asset", "AssetCatalog", "AssetCost",
"Person", "AssetEvent", "AssetFinancials", "AssetTelemetry", "AssetReview", "ExchangeRate",
"Wallet", "Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "PointRule",
"UserRole", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger",
"VerificationToken", "SystemParameter", "Document", "SubscriptionTier", "OrganizationSubscription",
"Organization", "CreditTransaction", "ServiceSpecialty", "AuditLog", "VehicleOwnership",
"OrganizationMember", "Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord"
"Asset",
"AssetCatalog",
"AssetCost",
"AssetEvent",
"Address",
"GeoPostalCode",
"GeoStreet",
"GeoStreetType",
"UserStats",
"PointsLedger",
"Vehicle",
"UserVehicle",
"VehicleCatalog",
"ServiceRecord"
] ]

Binary file not shown.

View File

@@ -1,133 +1,128 @@
import uuid import uuid
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, Numeric, text from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.base_class import Base from app.db.base_class import Base
class AssetCatalog(Base): class AssetCatalog(Base):
"""Központi jármű katalógus (Admin/Bot által tölthető)"""
__tablename__ = "vehicle_catalog" __tablename__ = "vehicle_catalog"
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
make = Column(String, index=True, nullable=False) make = Column(String, index=True, nullable=False)
model = Column(String, index=True, nullable=False) model = Column(String, index=True, nullable=False)
generation = Column(String) generation = Column(String)
year_from = Column(Integer) year_from = Column(Integer)
year_to = Column(Integer) year_to = Column(Integer)
vehicle_class = Column(String) # land, sea, air vehicle_class = Column(String)
fuel_type = Column(String) fuel_type = Column(String)
engine_code = Column(String) engine_code = Column(String)
factory_data = Column(JSON, server_default=text("'{}'::jsonb"))
assets = relationship("Asset", back_populates="catalog") assets = relationship("Asset", back_populates="catalog")
class Asset(Base): class Asset(Base):
"""A Jármű Identitás (Digital Twin törzsadatok)"""
__tablename__ = "assets" __tablename__ = "assets"
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
vin = Column(String(17), unique=True, index=True, nullable=False) vin = Column(String(17), unique=True, index=True, nullable=False)
license_plate = Column(String(20), index=True) license_plate = Column(String(20), index=True)
name = Column(String) name = Column(String)
year_of_manufacture = Column(Integer) year_of_manufacture = Column(Integer)
catalog_id = Column(Integer, ForeignKey("data.vehicle_catalog.id")) catalog_id = Column(Integer, ForeignKey("data.vehicle_catalog.id"))
# Nemzetközi mutatók
quality_index = Column(Numeric(3, 2), default=1.00)
system_mileage = Column(Integer, default=0)
mileage_unit = Column(String(10), default="km") # Nemzetközi: km, miles, hours
is_verified = Column(Boolean, default=False) is_verified = Column(Boolean, default=False)
status = Column(String(20), default="active") status = Column(String(20), default="active")
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
catalog = relationship("AssetCatalog", back_populates="assets") catalog = relationship("AssetCatalog", back_populates="assets")
financials = relationship("AssetFinancials", back_populates="asset", uselist=False)
telemetry = relationship("AssetTelemetry", back_populates="asset", uselist=False)
assignments = relationship("AssetAssignment", back_populates="asset") assignments = relationship("AssetAssignment", back_populates="asset")
events = relationship("AssetEvent", back_populates="asset") events = relationship("AssetEvent", back_populates="asset")
costs = relationship("AssetCost", back_populates="asset") costs = relationship("AssetCost", back_populates="asset")
reviews = relationship("AssetReview", back_populates="asset")
ownership_history = relationship("VehicleOwnership", back_populates="vehicle")
class AssetFinancials(Base):
__tablename__ = "asset_financials"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), unique=True)
acquisition_price = Column(Numeric(18, 2))
acquisition_date = Column(DateTime)
financing_type = Column(String)
residual_value_estimate = Column(Numeric(18, 2))
asset = relationship("Asset", back_populates="financials")
class AssetTelemetry(Base):
__tablename__ = "asset_telemetry"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), unique=True)
current_mileage = Column(Integer, default=0)
mileage_unit = Column(String(10), default="km")
vqi_score = Column(Numeric(5, 2), default=100.00)
dbs_score = Column(Numeric(5, 2), default=100.00)
asset = relationship("Asset", back_populates="telemetry")
class AssetReview(Base):
__tablename__ = "asset_reviews"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False)
overall_rating = Column(Integer)
criteria_scores = Column(JSON, server_default=text("'{}'::jsonb"))
comment = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
asset = relationship("Asset", back_populates="reviews")
class AssetAssignment(Base): class AssetAssignment(Base):
"""Birtoklás követése (Kié a jármű és mettől meddig)"""
__tablename__ = "asset_assignments" __tablename__ = "asset_assignments"
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False) asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False) organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False)
assigned_at = Column(DateTime(timezone=True), server_default=func.now()) assigned_at = Column(DateTime(timezone=True), server_default=func.now())
released_at = Column(DateTime(timezone=True), nullable=True) released_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String(30), default="active") status = Column(String(30), default="active")
notes = Column(String)
asset = relationship("Asset", back_populates="assignments") asset = relationship("Asset", back_populates="assignments")
organization = relationship("Organization", back_populates="assets")
class AssetEvent(Base): class AssetEvent(Base):
"""Élettörténeti események (Szerviz, km-óra állások, balesetek)"""
__tablename__ = "asset_events" __tablename__ = "asset_events"
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False) asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
event_type = Column(String(50), nullable=False)
event_type = Column(String(50), nullable=False)
event_date = Column(DateTime(timezone=True), server_default=func.now())
recorded_mileage = Column(Integer) recorded_mileage = Column(Integer)
description = Column(String)
data = Column(JSON, server_default=text("'{}'::jsonb")) data = Column(JSON, server_default=text("'{}'::jsonb"))
asset = relationship("Asset", back_populates="events") asset = relationship("Asset", back_populates="events")
class AssetCost(Base): class AssetCost(Base):
"""
Költségkezelő modell: Bruttó/Nettó/ÁFA és deviza támogatás.
"""
__tablename__ = "asset_costs" __tablename__ = "asset_costs"
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False) asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False) organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False)
driver_id = Column(Integer, ForeignKey("data.users.id"), nullable=True)
cost_type = Column(String(50), nullable=False) # fuel, service, tax, insurance, toll cost_type = Column(String(50), nullable=False)
amount_local = Column(Numeric(18, 2), nullable=False)
# Pénzügyi adatok currency_local = Column(String(3), nullable=False)
amount = Column(Numeric(18, 2), nullable=False) # Bruttó összeg amount_eur = Column(Numeric(18, 2), nullable=True)
net_amount = Column(Numeric(18, 2)) # Nettó összeg net_amount_local = Column(Numeric(18, 2))
vat_amount = Column(Numeric(18, 2)) # ÁFA érték vat_rate = Column(Numeric(5, 2))
vat_rate = Column(Numeric(5, 2)) # ÁFA kulcs (pl. 27.00) exchange_rate_used = Column(Numeric(18, 6))
# Nemzetközi deviza kezelés
currency = Column(String(3), default="HUF") # Riportálási deviza
original_currency = Column(String(3)) # Számla eredeti devizája
exchange_rate_at_cost = Column(Numeric(18, 6)) # Rögzítéskori árfolyam
date = Column(DateTime(timezone=True), server_default=func.now()) date = Column(DateTime(timezone=True), server_default=func.now())
description = Column(String)
invoice_id = Column(String)
mileage_at_cost = Column(Integer) mileage_at_cost = Column(Integer)
data = Column(JSON, server_default=text("'{}'::jsonb")) data = Column(JSON, server_default=text("'{}'::jsonb"))
asset = relationship("Asset", back_populates="costs") asset = relationship("Asset", back_populates="costs")
class ExchangeRate(Base): class ExchangeRate(Base):
"""Napi árfolyamok tárolása (ECB/MNB adatok alapján)"""
__tablename__ = "exchange_rates" __tablename__ = "exchange_rates"
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
id = Column(Integer, primary_key=True, index=True)
base_currency = Column(String(3), default="EUR") base_currency = Column(String(3), default="EUR")
target_currency = Column(String(3), nullable=False) target_currency = Column(String(3), unique=True)
rate = Column(Numeric(18, 6), nullable=False) rate = Column(Numeric(18, 6), nullable=False)
rate_date = Column(DateTime(timezone=False), index=True) rate_date = Column(DateTime, server_default=func.now())
provider = Column(String(50), default="ECB") updated_at = Column(DateTime(timezone=True), onupdate=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,63 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID
from app.db.base import Base
import enum
# A Python enum marad, de a Column definíciónál pontosítunk
class CompanyRole(str, enum.Enum):
OWNER = "owner"
MANAGER = "manager"
DRIVER = "driver"
class Company(Base):
__tablename__ = "companies"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
tax_number = Column(String, nullable=True)
subscription_tier = Column(String, default="free")
owner_id = Column(Integer, ForeignKey("data.users.id"), nullable=False)
members = relationship("CompanyMember", back_populates="company", cascade="all, delete-orphan")
assignments = relationship("VehicleAssignment", back_populates="company")
class CompanyMember(Base):
__tablename__ = "company_members"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
company_id = Column(Integer, ForeignKey("data.companies.id"), nullable=False)
user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False)
# JAVÍTÁS: Kifejezetten megadjuk a natív Postgres típust
role = Column(
PG_ENUM('owner', 'manager', 'driver', name='companyrole', schema='data', create_type=False),
nullable=False
)
can_edit_service = Column(Boolean, default=False)
can_see_costs = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
company = relationship("Company", back_populates="members")
user = relationship("User")
class VehicleAssignment(Base):
__tablename__ = "vehicle_assignments"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
company_id = Column(Integer, ForeignKey("data.companies.id"), nullable=False)
vehicle_id = Column(UUID(as_uuid=True), ForeignKey("data.vehicles.id"), nullable=False)
driver_id = Column(Integer, ForeignKey("data.users.id"), nullable=False)
start_date = Column(DateTime(timezone=True), server_default=func.now())
end_date = Column(DateTime(timezone=True), nullable=True)
notes = Column(String, nullable=True)
company = relationship("Company", back_populates="assignments")
vehicle = relationship("Vehicle") # Itt már a Vehicle-re hivatkozunk
driver = relationship("User", foreign_keys=[driver_id])

View File

@@ -1,7 +1,8 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, JSON, Numeric from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, JSON, Numeric
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.base import Base # JAVÍTVA: Import közvetlenül a base_class-ból
from app.db.base_class import Base
class SubscriptionTier(Base): class SubscriptionTier(Base):
__tablename__ = "subscription_tiers" __tablename__ = "subscription_tiers"
@@ -39,4 +40,4 @@ class ServiceSpecialty(Base):
name = Column(String, nullable=False) name = Column(String, nullable=False)
slug = Column(String, unique=True) slug = Column(String, unique=True)
parent = relationship("ServiceSpecialty", remote_side=[id], backref="children") parent = relationship("ServiceSpecialty", remote_side=[id], backref="children")

View File

@@ -2,7 +2,8 @@ from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func from sqlalchemy.sql import func
import uuid import uuid
from app.db.base import Base # JAVÍTVA: Közvetlenül a base_class-ból importálunk, nem a base-ből!
from app.db.base_class import Base
class Document(Base): class Document(Base):
__tablename__ = "documents" __tablename__ = "documents"

View File

@@ -1,17 +0,0 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.sql import func
from app.db.base import Base
class EmailLog(Base):
__tablename__ = "email_logs"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, nullable=True) # Hozzáadva
recipient = Column(String, index=True) # Hozzáadva
email = Column(String, index=True)
email_type = Column(String) # Frissítve a kódhoz
type = Column(String) # Megtartva a kompatibilitás miatt
provider_id = Column(Integer) # Hozzáadva
status = Column(String) # Hozzáadva
sent_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,21 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean, JSON, Float
from app.db.base import Base
class EmailProviderConfig(Base):
__tablename__ = "email_provider_configs"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), unique=True) # Pl: SendGrid_Main, Office365_Backup
provider_type = Column(String(20)) # SENDGRID, SMTP, MAILGUN
priority = Column(Integer, default=1) # 1 = legfontosabb
# JSON-ban tároljuk a paramétereket (host, port, api_key, user, stb.)
settings = Column(JSON, nullable=False)
is_active = Column(Boolean, default=True)
# Failover figyelés
fail_count = Column(Integer, default=0)
max_fail_threshold = Column(Integer, default=3) # Hány hiba után kapcsoljon le?
success_rate = Column(Float, default=100.0) # Statisztika az adminnak

View File

@@ -1,30 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Numeric
from sqlalchemy.sql import func
from app.db.base import Base
class EmailProvider(Base):
__tablename__ = 'email_providers'
__table_args__ = {'schema': 'data'}
id = Column(Integer, PRIMARY KEY=True)
name = Column(String(50), nullable=False)
priority = Column(Integer, default=1)
provider_type = Column(String(10), default='SMTP')
host = Column(String(255))
port = Column(Integer)
username = Column(String(255))
password_hash = Column(String(255))
is_active = Column(Boolean, default=True)
daily_limit = Column(Integer, default=300)
current_daily_usage = Column(Integer, default=0)
class EmailLog(Base):
__tablename__ = 'email_logs'
__table_args__ = {'schema': 'data'}
id = Column(Integer, PRIMARY KEY=True)
user_id = Column(Integer, ForeignKey('data.users.id'), nullable=True)
email_type = Column(String(50))
recipient = Column(String(255))
provider_id = Column(Integer, ForeignKey('data.email_providers.id'))
status = Column(String(20))
sent_at = Column(DateTime(timezone=True), server_default=func.now())
error_message = Column(Text)

View File

@@ -1,17 +0,0 @@
from sqlalchemy import Column, Integer, String, Text, Enum
import enum
from app.db.base import Base
class EmailType(str, enum.Enum):
REGISTRATION = "REGISTRATION"
PASSWORD_RESET = "PASSWORD_RESET"
GDPR_NOTICE = "GDPR_NOTICE"
class EmailTemplate(Base):
__tablename__ = "email_templates"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
type = Column(Enum(EmailType), unique=True, index=True)
subject = Column(String(255), nullable=False)
body_html = Column(Text, nullable=False) # Adminról szerkeszthető HTML tartalom

View File

@@ -1,50 +0,0 @@
import enum
from sqlalchemy import Column, Integer, String, ForeignKey, Enum, DateTime, Boolean, Date, JSON
from sqlalchemy.sql import func
from app.db.base import Base
# Költség Kategóriák
class ExpenseCategory(str, enum.Enum):
PURCHASE_PRICE = "PURCHASE_PRICE" # Vételár
TRANSFER_TAX = "TRANSFER_TAX" # Vagyonszerzési illeték
ADMIN_FEE = "ADMIN_FEE" # Eredetiség, forgalmi, törzskönyv
VEHICLE_TAX = "VEHICLE_TAX" # Gépjárműadó
INSURANCE = "INSURANCE" # Biztosítás
REFUELING = "REFUELING" # Tankolás
SERVICE = "SERVICE" # Szerviz / Javítás
PARKING = "PARKING" # Parkolás
TOLL = "TOLL" # Autópálya matrica
FINE = "FINE" # Bírság
TUNING_ACCESSORIES = "TUNING_ACCESSORIES" # Extrák
OTHER = "OTHER" # Egyéb
class VehicleEvent(Base):
__tablename__ = "vehicle_events"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
vehicle_id = Column(Integer, ForeignKey("data.user_vehicles.id"), nullable=False)
# Esemény típusa
event_type = Column(Enum(ExpenseCategory, schema="data", name="expense_category_enum"), nullable=False)
date = Column(Date, nullable=False)
# Kilométeróra (KÖTELEZŐ!)
odometer_value = Column(Integer, nullable=False)
odometer_anomaly = Column(Boolean, default=False) # Ha csökkenést észlelünk, True lesz
# Pénzügyek
cost_amount = Column(Integer, nullable=False, default=0) # HUF
# Leírás és Képek
description = Column(String, nullable=True)
image_paths = Column(JSON, nullable=True) # Lista a feltöltött képek (számla, fotó) útvonalairól
# Kapcsolat a szolgáltatóval
# Ha is_diy=True, akkor a user maga csinálta.
# Ha is_diy=False és service_provider_id=None, akkor ismeretlen helyen készült.
is_diy = Column(Boolean, default=False)
service_provider_id = Column(Integer, ForeignKey("data.service_providers.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,63 +1,30 @@
# /opt/service_finder/backend/app/models/history.py
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON, Date, Text from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON, Date, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.base import Base from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from app.db.base_class import Base
# --- 1. Jármű Birtoklási Előzmények (Ownership History) ---
# Ez a tábla mondja meg, kié volt az autó egy adott időpillanatban.
# Így biztosítjuk, hogy a régi tulajdonos adatai védve legyenek az újtól.
class VehicleOwnership(Base): class VehicleOwnership(Base):
__tablename__ = "vehicle_ownerships" __tablename__ = "vehicle_ownerships"
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
vehicle_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
# Kapcsolatok
vehicle_id = Column(Integer, ForeignKey("data.user_vehicles.id"), nullable=False)
user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False) user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False)
start_date = Column(Date, nullable=False, default=func.current_date())
# Időszak end_date = Column(Date, nullable=True)
start_date = Column(Date, nullable=False, default=func.current_date()) # Mikor került hozzá
end_date = Column(Date, nullable=True) # Ha NULL, akkor ő a jelenlegi tulajdonos!
# Jegyzet (pl. adásvételi szerződés száma)
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
# SQLAlchemy kapcsolatok (visszahivatkozások a fő modellekben kellenek majd) vehicle = relationship("Asset", back_populates="ownership_history")
vehicle = relationship("UserVehicle", back_populates="ownership_history") user = relationship("User", back_populates="ownership_history")
user = relationship("User", back_populates="owned_vehicles")
# --- 2. Audit Log (A "Fekete Doboz") ---
# Minden kritikus módosítást itt tárolunk. Ez a rendszer "igazságügyi naplója".
class AuditLog(Base): class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
# KI? (A felhasználó, aki a műveletet végezte)
user_id = Column(Integer, ForeignKey("data.users.id"), nullable=True) user_id = Column(Integer, ForeignKey("data.users.id"), nullable=True)
target_type = Column(String, index=True)
# MIT? (Milyen objektumot érintett?) target_id = Column(String, index=True)
target_type = Column(String, index=True) # pl. "vehicle", "cost", "user_profile" action = Column(String, nullable=False)
target_id = Column(Integer, index=True) # pl. az autó ID-ja
# HOGYAN?
action = Column(String, nullable=False) # CREATE, UPDATE, DELETE, LOGIN_FAILED, EXPORT_DATA
# RÉSZLETEK (Mi változott?)
# Pl: {"field": "odometer", "old_value": 150000, "new_value": 120000} <- Visszatekerés gyanú!
changes = Column(JSON, nullable=True) changes = Column(JSON, nullable=True)
# BIZTONSÁG
ip_address = Column(String, nullable=True) # Honnan jött a kérés?
user_agent = Column(String, nullable=True) # Milyen böngészőből?
# MIKOR?
timestamp = Column(DateTime(timezone=True), server_default=func.now()) timestamp = Column(DateTime(timezone=True), server_default=func.now())
# Kapcsolat (Opcionális, csak ha le akarjuk kérdezni a user adatait a logból)
user = relationship("User") user = relationship("User")

View File

@@ -12,6 +12,7 @@ class UserRole(str, enum.Enum):
service = "service" service = "service"
fleet_manager = "fleet_manager" fleet_manager = "fleet_manager"
driver = "driver" driver = "driver"
superadmin = "superadmin" # Hozzáadva a biztonság kedvéért
class Person(Base): class Person(Base):
__tablename__ = "persons" __tablename__ = "persons"
@@ -51,34 +52,37 @@ class User(Base):
region_code = Column(String, default="HU") region_code = Column(String, default="HU")
is_deleted = Column(Boolean, default=False) is_deleted = Column(Boolean, default=False)
person_id = Column(BigInteger, ForeignKey("data.persons.id"), nullable=True) person_id = Column(BigInteger, ForeignKey("data.persons.id"), nullable=True)
preferred_language = Column(String(5), default="hu")
preferred_currency = Column(String(3), default="HUF")
timezone = Column(String(50), default="Europe/Budapest")
person = relationship("Person", back_populates="users") # RBAC & SCOPE mezők (Visszaállítva a DB sémához)
wallet = relationship("Wallet", back_populates="user", uselist=False) scope_level = Column(String(30), server_default="individual")
scope_id = Column(String(50))
# Itt a trükk: csak a string hivatkozás marad, így nincs import hiba, custom_permissions = Column(JSON, server_default=text("'{}'::jsonb"))
# de a SQLAlchemy tudni fogja, hogy a UserStats-ra gondolunk.
stats = relationship("UserStats", back_populates="user", uselist=False)
owned_organizations = relationship("Organization", back_populates="owner", lazy="select")
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
# Kapcsolatok
person = relationship("Person", back_populates="users")
wallet = relationship("Wallet", back_populates="user", uselist=False)
stats = relationship("UserStats", back_populates="user", uselist=False)
ownership_history = relationship("VehicleOwnership", back_populates="user")
owned_organizations = relationship("Organization", back_populates="owner")
class Wallet(Base): class Wallet(Base):
"""Kétoszlopos pénztárca: Coin (Szerviz) és Kredit (Prémium)."""
__tablename__ = "wallets" __tablename__ = "wallets"
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("data.users.id"), unique=True) user_id = Column(Integer, ForeignKey("data.users.id"), unique=True)
coin_balance = Column(Numeric(18, 2), default=0.00) coin_balance = Column(Numeric(18, 2), default=0.00)
credit_balance = Column(Numeric(18, 2), default=0.00) credit_balance = Column(Numeric(18, 2), default=0.00)
currency = Column(String(3), default="HUF") currency = Column(String(3), default="HUF")
user = relationship("User", back_populates="wallet") user = relationship("User", back_populates="wallet")
class VerificationToken(Base): class VerificationToken(Base):
__tablename__ = "verification_tokens" __tablename__ = "verification_tokens"
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
token = Column(PG_UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False) token = Column(PG_UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
user_id = Column(Integer, ForeignKey("data.users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("data.users.id", ondelete="CASCADE"), nullable=False)

View File

@@ -20,16 +20,16 @@ class Organization(Base):
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
# ÚJ MEZŐ: Egységes címkezelés (GeoService hibrid)
address_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.addresses.id"), nullable=True) address_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.addresses.id"), nullable=True)
# --- NÉVKEZELÉS --- full_name = Column(String, nullable=False)
full_name = Column(String, nullable=False) # Teljes hivatalos név name = Column(String, nullable=False)
name = Column(String, nullable=False) # Rövidített cégnév display_name = Column(String(50))
display_name = Column(String(50)) # Alkalmazáson belüli rövidítés
# --- ATOMIZÁLT CÍMKEZELÉS (Kompatibilitási réteg) --- default_currency = Column(String(3), default="HUF")
country_code = Column(String(2), default="HU")
language = Column(String(5), default="hu")
address_zip = Column(String(10)) address_zip = Column(String(10))
address_city = Column(String(100)) address_city = Column(String(100))
address_street_name = Column(String(150)) address_street_name = Column(String(150))
@@ -39,9 +39,7 @@ class Organization(Base):
address_stairwell = Column(String(20)) address_stairwell = Column(String(20))
address_floor = Column(String(20)) address_floor = Column(String(20))
address_door = Column(String(20)) address_door = Column(String(20))
country_code = Column(String(2), default="HU")
# --- ÜZLETI ADATOK ---
tax_number = Column(String(20), unique=True, index=True) tax_number = Column(String(20), unique=True, index=True)
reg_number = Column(String(50)) reg_number = Column(String(50))
@@ -65,7 +63,7 @@ class Organization(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Kapcsolatok # String alapú hivatkozás a körkörös import ellen
assets = relationship("AssetAssignment", back_populates="organization", cascade="all, delete-orphan") assets = relationship("AssetAssignment", back_populates="organization", cascade="all, delete-orphan")
members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan") members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan")
owner = relationship("User", back_populates="owned_organizations") owner = relationship("User", back_populates="owned_organizations")
@@ -76,13 +74,9 @@ class OrganizationMember(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False) organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False)
user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False) user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False)
role = Column(String, default="driver") # owner, manager, driver, service_staff role = Column(String, default="driver")
# JAVÍTVA: Jogosultságok JSONB mezője (can_add_asset, etc.)
permissions = Column(JSON, server_default=text("'{}'::jsonb")) permissions = Column(JSON, server_default=text("'{}'::jsonb"))
organization = relationship("Organization", back_populates="members") organization = relationship("Organization", back_populates="members")
user = relationship("app.models.identity.User") # Visszamutató kapcsolat a felhasználóra user = relationship("User") # Egyszerűsített string hivatkozás
# Kompatibilitási réteg
Organization.vehicles = Organization.assets

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, String, JSON, DateTime, Boolean
from sqlalchemy.sql import func
from app.db.base_class import Base
class SystemParameter(Base):
__tablename__ = "system_parameters"
__table_args__ = {"schema": "data", "extend_existing": True}
key = Column(String, primary_key=True, index=True, nullable=False)
value = Column(JSON, nullable=False)
is_active = Column(Boolean, default=True)
description = Column(String)
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())

View File

@@ -0,0 +1,16 @@
from sqlalchemy import Column, String, JSON, Integer, Boolean, DateTime, func
from app.db.base_class import Base
class SystemParameter(Base):
"""
Globális rendszerbeállítások (A meglévő data.system_parameters tábla alapján).
"""
__tablename__ = "system_parameters"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
key = Column(String(50), unique=True, index=True, nullable=False)
value = Column(JSON, nullable=False)
is_active = Column(Boolean, default=True)
description = Column(String, nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -1,54 +0,0 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Float, JSON, Date
from sqlalchemy.orm import relationship
from app.db.base import Base
# 1. Kategória (Autó, Motor, Kisteher...)
class VehicleCategory(Base):
__tablename__ = "vehicle_categories"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
name_key = Column(String, nullable=False) # i18n kulcs: 'CAR', 'MOTORCYCLE'
# 2. Márka (Audi, Honda, BMW...)
class VehicleMake(Base):
__tablename__ = "vehicle_makes"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
name = Column(String, unique=True, nullable=False)
logo_url = Column(String, nullable=True)
# 3. Modell és Generáció (pl. Audi A3 -> A3 8V)
class VehicleModel(Base):
__tablename__ = "vehicle_models"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
make_id = Column(Integer, ForeignKey("data.vehicle_makes.id"))
category_id = Column(Integer, ForeignKey("data.vehicle_categories.id"))
name = Column(String, nullable=False)
generation_name = Column(String, nullable=True) # pl: "8V Facelift"
production_start_year = Column(Integer, nullable=True)
production_end_year = Column(Integer, nullable=True)
# 4. Motor és Hajtáslánc (Technikai specifikációk)
class VehicleEngine(Base):
__tablename__ = "vehicle_engines"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
model_id = Column(Integer, ForeignKey("data.vehicle_models.id"))
engine_code = Column(String, nullable=True)
fuel_type = Column(String, nullable=False) # 'Petrol', 'Diesel', 'Hybrid', 'EV'
displacement_ccm = Column(Integer, nullable=True)
power_kw = Column(Integer, nullable=True)
torque_nm = Column(Integer, nullable=True)
transmission_type = Column(String, nullable=True) # 'Manual', 'Automatic'
gears_count = Column(Integer, nullable=True)
drive_type = Column(String, nullable=True) # 'FWD', 'RWD', 'AWD'
# 5. Opciók Katalógusa (Gyári extrák listája)
class VehicleOptionCatalog(Base):
__tablename__ = "vehicle_options_catalog"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
category = Column(String) # 'Security', 'Comfort', 'Multimedia'
name_key = Column(String) # 'MATRIX_LED'

View File

@@ -1,43 +1,54 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from typing import Optional from typing import Optional, Dict, Any, List
from uuid import UUID from uuid import UUID
from datetime import datetime from datetime import datetime
class AssetCreate(BaseModel): # --- KATALÓGUS SÉMÁK (Gyári adatok) ---
# Alapadatok class AssetCatalogBase(BaseModel):
make: str = Field(..., example="Ford") make: str
model: str = Field(..., example="Mondeo") model: str
vin: str = Field(..., min_length=17, max_length=17, description="Alvázszám") generation: Optional[str] = None
license_plate: Optional[str] = Field(None, max_length=20, example="RRR-555") year_from: Optional[int] = None
year_to: Optional[int] = None
# Nemzetközi és Admin szempontok vehicle_class: Optional[str] = None
vehicle_class: str = Field("land", description="land, sea, air - Admin által bővíthető") fuel_type: Optional[str] = None
fuel_type: str = Field(..., example="Diesel", description="Admin által definiált üzemanyag típusok") engine_code: Optional[str] = None
# Technikai adatok
engine_description: Optional[str] = Field(None, example="2.0 TDCI")
year_of_manufacture: int = Field(..., ge=1900, le=2100)
# Kezdő állapot
current_reading: int = Field(..., ge=0, description="Kezdő km/üzemóra állás")
reading_unit: str = Field("km", description="km, miles, hours - Nemzetközi beállítás")
# Felhasználói adatok
name: Optional[str] = Field(None, description="Egyedi elnevezés")
class AssetResponse(BaseModel): class AssetCatalogResponse(AssetCatalogBase):
id: UUID id: int
catalog_id: Optional[int] factory_data: Optional[Dict[str, Any]] = None # A robot által gyűjtött adatok
vin: str
license_plate: Optional[str] = None # JAVÍTVA: Lehet None a válaszban model_config = ConfigDict(from_attributes=True)
# --- JÁRMŰ SÉMÁK (Asset) ---
class AssetBase(BaseModel):
vin: str = Field(..., min_length=17, max_length=17)
license_plate: str
name: Optional[str] = None name: Optional[str] = None
fuel_type: str year_of_manufacture: Optional[int] = None
vehicle_class: str
is_verified: bool
year_of_manufacture: int
system_mileage: int
quality_index: float
created_at: datetime
class Config: class AssetCreate(AssetBase):
from_attributes = True # A létrehozáshoz kellenek a katalógus infók is
make: str
model: str
vehicle_class: Optional[str] = "land"
fuel_type: Optional[str] = None
current_reading: Optional[int] = 0
class AssetResponse(AssetBase):
id: UUID
catalog_id: int
is_verified: bool
status: str
model_config = ConfigDict(from_attributes=True)
# --- DIGITÁLIS IKER (Full Profile) ---
# Ez a séma felel a 9 pontos költség és a mélységi szerviz adatok átadásáért
class AssetFullProfile(BaseModel):
identity: Dict[str, Any]
telemetry: Dict[str, Any]
financial_summary: Dict[str, Any]
service_history: List[Dict[str, Any]]
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,35 @@
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
from datetime import datetime
from decimal import Decimal
from uuid import UUID
class AssetCostBase(BaseModel):
"""Alap költség adatok (Frontendről érkező bevitel)."""
cost_type: str = Field(..., description="fuel, service, fine, insurance, toll, etc.")
amount_local: Decimal = Field(..., description="A fizetett bruttó összeg helyi devizában")
currency_local: str = Field("HUF", min_length=3, max_length=3)
date: datetime = Field(default_factory=datetime.now)
mileage_at_cost: Optional[int] = Field(None, description="Kilométeróra állása a költség rögzítésekor")
description: Optional[str] = None
net_amount_local: Optional[Decimal] = None
vat_rate: Optional[Decimal] = Field(27.0, description="ÁFA kulcs (pl. 27.0)")
data: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Extra adatok (pl. helyszín, számlaszám)")
class AssetCostCreate(AssetCostBase):
"""Költség rögzítésekor használt séma."""
asset_id: UUID
organization_id: int
class AssetCostResponse(AssetCostBase):
"""Visszatérő adat modell a frontend felé."""
id: UUID
asset_id: UUID
organization_id: int
driver_id: Optional[int]
amount_eur: Decimal
exchange_rate_used: Decimal
created_at: Optional[datetime] = None
class Config:
from_attributes = True

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
from typing import Optional, Dict from typing import Optional, Dict, Any
from datetime import date from datetime import date
# --- STEP 1: LITE REGISTRATION --- # --- STEP 1: LITE REGISTRATION ---
@@ -9,6 +9,8 @@ class UserLiteRegister(BaseModel):
first_name: str first_name: str
last_name: str last_name: str
region_code: str = "HU" region_code: str = "HU"
lang: str = Field("hu", description="Választott nyelv kódja")
timezone: str = Field("Europe/Budapest", description="Felhasználó időzónája")
class UserLogin(BaseModel): class UserLogin(BaseModel):
email: EmailStr email: EmailStr
@@ -30,15 +32,15 @@ class UserKYCComplete(BaseModel):
birth_date: date birth_date: date
mothers_last_name: str mothers_last_name: str
mothers_first_name: str mothers_first_name: str
# Hibrid Címmezők
address_zip: str address_zip: str
address_city: str address_city: str
address_street_name: str address_street_name: str
address_street_type: str address_street_type: str
address_house_number: str address_house_number: str
address_hrsz: Optional[str] = None # Helyrajzi szám address_hrsz: Optional[str] = None
identity_docs: Dict[str, DocumentDetail] identity_docs: Dict[str, DocumentDetail]
ice_contact: ICEContact ice_contact: ICEContact
preferred_currency: Optional[str] = Field("HUF", max_length=3)
# --- COMMON & SECURITY --- # --- COMMON & SECURITY ---
class PasswordResetRequest(BaseModel): class PasswordResetRequest(BaseModel):
@@ -53,4 +55,13 @@ class PasswordResetConfirm(BaseModel):
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str token_type: str
is_active: bool is_active: bool
class TokenPayload(BaseModel):
"""JWT Token payload struktúrája validációhoz."""
sub: Optional[str] = None
role: Optional[str] = None
rank: Optional[int] = 0
scope_level: Optional[str] = None
scope_id: Optional[str] = None
region: Optional[str] = None

View File

@@ -1,46 +0,0 @@
from pydantic import BaseModel, EmailStr, Field, field_validator
from typing import Optional, List
from datetime import date
class UserRegister(BaseModel):
# --- AUTH ---
email: EmailStr = Field(..., example="teszt.user@profibot.hu")
password: Optional[str] = Field(None, min_length=8, description="Social login esetén üres maradhat")
# --- IDENTITY (KYC Step 2) ---
last_name: str = Field(..., min_length=2)
first_name: str = Field(..., min_length=2)
mothers_name: str = Field(..., description="Anyja születési neve")
birth_place: Optional[str] = None
birth_date: Optional[date] = None
# --- OKMÁNYOK (Banki szint) ---
id_card_number: Optional[str] = None
id_card_expiry: Optional[date] = None
driver_license_number: Optional[str] = None
driver_license_expiry: Optional[date] = None
driver_license_categories: List[str] = Field(default_factory=list, example=["B", "A"])
# --- SPECIÁLIS ENGEDÉLYEK ---
boat_license_number: Optional[str] = None
pilot_license_number: Optional[str] = None
# --- SYSTEM ---
region_code: str = Field(default="HU")
invite_token: Optional[str] = None
social_provider: Optional[str] = None
social_id: Optional[str] = None
@field_validator('region_code')
@classmethod
def validate_region(cls, v: str) -> str:
return v.upper() if v else "HU"
class Token(BaseModel):
access_token: str
token_type: str
class UserLogin(BaseModel):
email: EmailStr
password: str

View File

@@ -15,6 +15,8 @@ class CorpOnboardIn(BaseModel):
tax_number: str tax_number: str
country_code: str = "HU" country_code: str = "HU"
language: str = Field("hu", description="A szervezet alapértelmezett nyelve")
default_currency: str = Field("HUF", description="A szervezet alapértelmezett pénzneme")
reg_number: Optional[str] = None reg_number: Optional[str] = None
# Atomizált Címkezelés # Atomizált Címkezelés

View File

@@ -0,0 +1,43 @@
import asyncio
import json
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.models.system_config import SystemParameter
from app.core.config import settings
async def seed_system():
engine = create_async_engine(settings.DATABASE_URL)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
params = [
{
"key": "fuel_types",
"value": ["Benzin (95)", "Benzin (100)", "Dízel", "Prémium Dízel", "LPG", "Elektromos", "Hibrid"],
"description": "Rendszerben használható üzemanyag típusok"
},
{
"key": "currencies",
"value": ["HUF", "EUR", "USD", "GBP"],
"description": "Támogatott pénznemek"
},
{
"key": "expense_categories",
"value": ["Üzemanyag", "Szerviz", "Biztosítás", "Autópálya matrica", "Parkolás", "Adó", "Egyéb"],
"description": "Költség kategóriák"
}
]
for p in params:
# Megnézzük, létezik-e már
from sqlalchemy import select
result = await session.execute(select(SystemParameter).where(SystemParameter.key == p["key"]))
if not result.scalar_one_or_none():
new_param = SystemParameter(**p)
session.add(new_param)
await session.commit()
print("✅ Rendszer paraméterek sikeresen feltöltve!")
if __name__ == "__main__":
asyncio.run(seed_system())

View File

@@ -1,58 +1,97 @@
import asyncio import asyncio
from datetime import datetime import logging
import uuid
from sqlalchemy import select from sqlalchemy import select
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.legal import LegalDocument from app.models import (
from app.models.email_template import EmailTemplate, EmailType User, Person, UserRole, SystemParameter,
from app.models.email_provider import EmailProviderConfig PointRule, LevelConfig, SubscriptionTier, UserStats
)
from app.core.security import get_password_hash
from app.core.config import settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def seed_data(): async def seed_data():
async with SessionLocal() as db: async with SessionLocal() as db:
# 1. Jogi dokumentumok (HU) logger.info("🚀 Alapadatok feltöltése biztonságos módban...")
legal_docs = [
LegalDocument( admin_email = settings.INITIAL_ADMIN_EMAIL
title="Általános Szerződési Feltételek", admin_password = settings.INITIAL_ADMIN_PASSWORD
content="Ide jön az ÁSZF szövege... Kérjük görgessen az aljáig.",
version="v1.0", if not admin_email or not admin_password:
region_code="HU", logger.error("❌ HIBA: INITIAL_ADMIN_EMAIL vagy PASSWORD nincs beállítva!")
language="hu" return
),
LegalDocument( stmt = select(User).where(User.email == admin_email)
title="Adatkezelési Tájékoztató (GDPR)", admin_exists = (await db.execute(stmt)).scalar_one_or_none()
content="Ide jön a GDPR szövege... Kérjük görgessen az aljáig.",
version="v1.0", if not admin_exists:
region_code="HU", new_person = Person(
language="hu" first_name="Rendszer",
last_name="Adminisztrátor",
id_uuid=uuid.uuid4()
) )
db.add(new_person)
await db.flush()
new_admin = User(
email=admin_email,
hashed_password=get_password_hash(admin_password),
role=UserRole.admin,
is_active=True,
# JAVÍTÁS: is_verified eltávolítva, mert nincs ilyen mező a modellben
person_id=new_person.id
)
db.add(new_admin)
await db.flush()
db.add(UserStats(user_id=new_admin.id, total_xp=0, current_level=1))
logger.info(f"✅ Admin létrehozva: {admin_email}")
# --- 1. Értékelési szempontok (Admin Motor) ---
criteria_key = "ASSET_REVIEW_CRITERIA"
stmt_crit = select(SystemParameter).where(SystemParameter.key == criteria_key)
if not (await db.execute(stmt_crit)).scalar_one_or_none():
db.add(SystemParameter(
key=criteria_key,
value=["Kényelem", "Fogyasztás", "Megbízhatóság", "Vezetési élmény", "Szervizigény"],
description="Járműértékelési szempontok"
))
# --- 2. Gamification Pontszabályok ---
rules = [
("ASSET_REGISTER", 100, "Új jármű felvétele"),
("ASSET_REVIEW", 75, "Jármű értékelése"),
("COST_RECORD", 50, "Költség/Tankolás rögzítése")
] ]
for key, pts, desc in rules:
# 2. Email Sablon (Regisztráció) stmt_rule = select(PointRule).where(PointRule.action_key == key)
reg_template = EmailTemplate( if not (await db.execute(stmt_rule)).scalar_one_or_none():
type=EmailType.REGISTRATION, db.add(PointRule(action_key=key, points=pts, description=desc))
subject="Üdvözöljük a Service Finderben!",
body_html="""
<h3>Kedves {{ name }}!</h3>
<p>Köszönjük a regisztrációt! Az aktiváláshoz kattints ide:</p>
<a href="{{ link }}">Fiók aktiválása</a>
<p>A link 24 óráig érvényes.</p>
"""
)
# 3. Email Szolgáltató (SendGrid) # --- 3. Gamification Szintek ---
sendgrid_provider = EmailProviderConfig( stmt_level = select(LevelConfig)
name="SendGrid_Primary", if not (await db.execute(stmt_level)).first():
provider_type="SENDGRID", db.add_all([
priority=1, LevelConfig(level_number=1, min_points=0, rank_name="Kezdő Sofőr"),
settings={"api_key": "YOUR_SENDGRID_KEY_HERE"}, # Ezt majd az adminon írjuk át LevelConfig(level_number=2, min_points=500, rank_name="Tapasztalt Vezető"),
max_fail_threshold=3 LevelConfig(level_number=3, min_points=2000, rank_name="Flotta Mester")
) ])
# --- 4. Előfizetési csomagok (MVP korlátok) ---
stmt_tier = select(SubscriptionTier)
if not (await db.execute(stmt_tier)).first():
db.add_all([
SubscriptionTier(name="Ingyenes", rules={"max_assets": 1, "reports": False}),
SubscriptionTier(name="Prémium", rules={"max_assets": 5, "reports": True}),
SubscriptionTier(name="Flotta", rules={"max_assets": 100, "reports": True})
])
db.add_all(legal_docs)
db.add(reg_template)
db.add(sendgrid_provider)
await db.commit() await db.commit()
print("🌱 Alapadatok sikeresen feltöltve!") logger.info(" A rendszer alapadatai és a Gamification motor készen áll!")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(seed_data()) asyncio.run(seed_data())

View File

@@ -0,0 +1,107 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models import (
User, Organization, OrganizationMember, Asset, AssetCatalog,
AssetTelemetry, AssetFinancials, AssetCost, AssetEvent
)
from app.models.organization import OrgType
async def seed_test_scenario():
async with SessionLocal() as db:
print("🚀 Teszt ökoszisztéma felépítése a meglévő modellek alapján...")
# 1. Admin lekérése
admin = (await db.execute(select(User))).scalars().first()
if not admin:
print("❌ Hiba: Nincs admin az adatbázisban!")
return
# 2. SZERVEZETEK (A te OrgType enumod alapján)
# Privát flotta
private_org = Organization(
name="Kincses Privát",
full_name="Kincses Magánflotta és Garázs",
org_type=OrgType.individual,
owner_id=admin.id
)
# Céges flotta (OrgType.business-t használunk!)
company_org = Organization(
name="ProfiBot Fleet",
full_name="ProfiBot Software Solutions Kft.",
org_type=OrgType.business,
owner_id=admin.id
)
# Szolgáltatók
service_org = Organization(
name="Mester Szerviz",
full_name="Mester Autójavító és Vizsgabázis Kft.",
org_type=OrgType.service,
owner_id=admin.id
)
gas_station = Organization(
name="MOL Digit",
full_name="MOL Digitális Töltőállomás 001",
org_type=OrgType.service_provider, # OrgType.service_provider-t használunk!
owner_id=admin.id
)
db.add_all([private_org, company_org, service_org, gas_station])
await db.flush()
# Tagságok rögzítése
db.add(OrganizationMember(user_id=admin.id, organization_id=private_org.id, role="owner"))
db.add(OrganizationMember(user_id=admin.id, organization_id=company_org.id, role="owner"))
# 3. RÉTESZLETES JÁRMŰ ADAT (Tesla Model 3)
catalog = AssetCatalog(
make="Tesla", model="Model 3", generation="Long Range",
year_from=2021, fuel_type="Electric",
factory_data={
"battery": "75 kWh", "power": "366 kW", "torque": "493 Nm",
"tire_size": "235/45 R18", "oil_type": "None (EV)"
}
)
db.add(catalog)
await db.flush()
vehicle = Asset(
vin="5YJ3E1EB8LF000000", license_plate="TES-777-EV",
name="Főnök Teslája", year_of_manufacture=2021,
catalog_id=catalog.id, status="active"
)
db.add(vehicle)
await db.flush()
# Telemetria és Pénzügyi modulok
db.add(AssetTelemetry(asset_id=vehicle.id, current_mileage=45200, vqi_score=100.0, dbs_score=100.0))
db.add(AssetFinancials(asset_id=vehicle.id, acquisition_price=18500000))
# 4. KÖLTSÉGEK (9 kategória szimulálása)
costs_data = [
("FUEL", 15000, "Szupertöltés MOL", gas_station.id),
("MAINTENANCE", 120000, "Éves szerviz + fékfolyadék", service_org.id),
("TIRES", 240000, "Michelin Pilot Sport szett", None),
("INSURANCE", 45000, "Allianz Casco", None),
("TAX", 0, "Zöld rendszám kedvezmény", None),
("TOLL", 5500, "Pest megyei e-matrica", None),
("CLEANING", 8500, "Nano bevonat + Mosás", None),
("PARKING", 2400, "Airport Parking", None),
("FINE", 0, "Nincs aktív bírság", None)
]
for c_type, amount, desc, vendor_id in costs_data:
db.add(AssetCost(
asset_id=vehicle.id, organization_id=company_org.id,
cost_type=c_type, amount=amount, currency="HUF",
data={"description": desc, "vendor_id": vendor_id},
date=datetime.now() - timedelta(days=2)
))
await db.commit()
print("✅ Siker! Flották, Tesla és a 9 költségtípus rögzítve.")
if __name__ == "__main__":
asyncio.run(seed_test_scenario())

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 fastapi.encoders import jsonable_encoder
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet 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.models.organization import Organization, OrganizationMember, OrgType
from app.schemas.auth import UserLiteRegister, UserKYCComplete from app.schemas.auth import UserLiteRegister, UserKYCComplete
from app.core.security import get_password_hash, verify_password 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): async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
""" """
Step 1: Lite Regisztráció (Master Book 1.1) 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: try:
# Ideiglenes Person rekord a KYC-ig # Ideiglenes Person rekord a KYC-ig
@@ -45,7 +45,10 @@ class AuthService:
role=UserRole.user, role=UserRole.user,
is_active=False, is_active=False,
is_deleted=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) db.add(new_user)
await db.flush() await db.flush()
@@ -60,12 +63,14 @@ class AuthService:
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_hours)) 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}" verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
await email_manager.send_email( await email_manager.send_email(
recipient=user_in.email, recipient=user_in.email,
template_key="registration", template_key="reg", # hu.json: email.reg_subject, reg_greeting stb.
variables={"first_name": user_in.first_name, "link": verification_link} variables={"first_name": user_in.first_name, "link": verification_link},
lang=user_in.lang # Dinamikus nyelvválasztás
) )
await db.commit() await db.commit()
@@ -81,6 +86,7 @@ class AuthService:
""" """
1.3. Fázis: Atomi Tranzakció & Shadow Identity 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. 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: try:
# 1. Aktuális technikai User lekérése # 1. Aktuális technikai User lekérése
@@ -89,8 +95,11 @@ class AuthService:
user = res.scalar_one_or_none() user = res.scalar_one_or_none()
if not user: return None if not user: return None
# 2. Shadow Identity Ellenőrzése (Anyja neve + Születési hely + Idő alapján) # --- PÉNZNEM PREFERENCIA FRISSÍTÉSE ---
# Globális keresés, régiótól függetlenül 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_( identity_stmt = select(Person).where(and_(
Person.mothers_last_name == kyc_in.mothers_last_name, Person.mothers_last_name == kyc_in.mothers_last_name,
Person.mothers_first_name == kyc_in.mothers_first_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() existing_person = (await db.execute(identity_stmt)).scalar_one_or_none()
if existing_person: if existing_person:
# Visszatérő identitás: A User-t a régi Person-hoz kötjük
user.person_id = existing_person.id user.person_id = existing_person.id
active_person = existing_person active_person = existing_person
logger.info(f"Shadow Identity linked: User {user_id} -> Person {existing_person.id}") 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 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_last_name = kyc_in.mothers_last_name
active_person.mothers_first_name = kyc_in.mothers_first_name active_person.mothers_first_name = kyc_in.mothers_first_name
active_person.birth_place = kyc_in.birth_place 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.ice_contact = jsonable_encoder(kyc_in.ice_contact)
active_person.is_active = True 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( new_org = Organization(
full_name=f"{active_person.last_name} {active_person.first_name} Egyéni Flotta", full_name=f"{active_person.last_name} {active_person.first_name} Egyéni Flotta",
name=f"{active_person.last_name} Flotta", name=f"{active_person.last_name} Flotta",
@@ -137,7 +145,11 @@ class AuthService:
owner_id=user.id, owner_id=user.id,
is_transferable=False, is_transferable=False,
is_active=True, 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) db.add(new_org)
await db.flush() await db.flush()
@@ -150,8 +162,13 @@ class AuthService:
permissions={"can_add_asset": True, "can_view_costs": True, "is_admin": True} permissions={"can_add_asset": True, "can_view_costs": True, "is_admin": True}
)) ))
# 7. Wallet & Stats (Friss kezdés 0 ponttal) # 7. Wallet & Stats
db.add(Wallet(user_id=user.id, coin_balance=0, credit_balance=0)) 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)) db.add(UserStats(user_id=user.id, total_xp=0, current_level=1))
# 8. Aktiválás # 8. Aktiválás
@@ -197,7 +214,6 @@ class AuthService:
@staticmethod @staticmethod
async def initiate_password_reset(db: AsyncSession, email: str): 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)) stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
user = (await db.execute(stmt)).scalar_one_or_none() 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)) 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}" reset_link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
await email_manager.send_email( await email_manager.send_email(
recipient=email, recipient=email,
template_key="password_reset", template_key="pwd_reset", # hu.json: email.pwd_reset_subject stb.
variables={"link": reset_link} variables={"link": reset_link},
lang=user.preferred_language # Adatbázisból kinyert nyelv
) )
await db.commit() await db.commit()
return "success" 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) button_text = locale_manager.get(f"email.{template_key}_button", lang=lang)
footer = locale_manager.get(f"email.{template_key}_footer", 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""" return f"""
<html> <html>
<body style="font-family: Arial, sans-serif; color: #333; line-height: 1.6;"> <body style="font-family: Arial, sans-serif; color: #333; line-height: 1.6;">
@@ -30,8 +33,8 @@ class EmailManager:
</a> </a>
</div> </div>
<p style="font-size: 0.85em; color: #777; word-break: break-all;"> <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> {link_fallback_text}<br>
{variables.get('link')} <a href="{variables.get('link')}" style="color: #3498db;">{variables.get('link')}</a>
</p> </p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;"> <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> <p style="font-size: 0.8em; color: #999; text-align: center;">{footer}</p>
@@ -66,18 +69,11 @@ class EmailManager:
response = sg.send(message) response = sg.send(message)
logger.info(f"SendGrid Status: {response.status_code} for {recipient}") 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} return {"status": "success", "provider": "sendgrid", "code": response.status_code}
except Exception as e: except Exception as e:
logger.error(f"SendGrid Kritikus Hiba: {str(e)}") logger.error(f"SendGrid Kritikus Hiba: {str(e)}")
# 2. SMTP Fallback # 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: try:
msg = MIMEMultipart() msg = MIMEMultipart()
msg["From"] = f"{settings.EMAILS_FROM_NAME} <{settings.EMAILS_FROM_EMAIL}>" 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.ext.asyncio import AsyncSession
from sqlalchemy import select, text from sqlalchemy import select
from app.models.gamification import UserStats, PointsLedger from app.models.gamification import UserStats, PointsLedger
from app.models.identity import User import math
class GamificationService: class GamificationService:
@staticmethod @staticmethod
async def award_points(db: AsyncSession, user_id: int, points: int, reason: str): async def process_activity(db: AsyncSession, user_id: int, xp_amount: int, social_amount: int, reason: str):
"""Pontok jóváírása (SQL szinkronizált points mezővel).""" """
new_entry = PointsLedger( XP növelés, Szintlépés csekk és Automata Kredit váltás.
user_id=user_id, """
points=points, # Javítva: points_change helyett points # 1. User statisztika lekérése
reason=reason stmt = select(UserStats).where(UserStats.user_id == user_id)
) stats = (await db.execute(stmt)).scalar_one_or_none()
db.add(new_entry)
result = await db.execute(select(UserStats).where(UserStats.user_id == user_id))
stats = result.scalar_one_or_none()
if not stats: 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) 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 # Külön log a váltásról
await db.flush() db.add(PointsLedger(user_id=user_id, reason=f"Auto-conversion: {new_credits} Credits", credits_change=new_credits))
return stats.total_points
await db.commit()
return stats

View File

@@ -1,34 +1,45 @@
# /app/services/harvester_base.py
import httpx import httpx
import logging
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.models.vehicle import VehicleCatalog from app.models.asset import AssetCatalog
logger = logging.getLogger(__name__)
class BaseHarvester: class BaseHarvester:
def __init__(self, category: str): def __init__(self, category: str):
self.category = category self.category = category # car, bike, truck
self.headers = {"User-Agent": "ServiceFinder-Harvester-Bot/2.0"} self.headers = {"User-Agent": "ServiceFinder-Harvester-Bot/2.0"}
async def check_exists(self, db: AsyncSession, brand: str, model: str): async def check_exists(self, db: AsyncSession, brand: str, model: str, gen: str = None):
"""Ellenőrzi, hogy az adott modell létezik-e már.""" """Ellenőrzi a katalógusban való létezést."""
stmt = select(VehicleCatalog).where( stmt = select(AssetCatalog).where(
VehicleCatalog.brand == brand, AssetCatalog.make == brand,
VehicleCatalog.model == model, AssetCatalog.model == model,
VehicleCatalog.category == self.category AssetCatalog.vehicle_class == self.category
) )
if gen:
stmt = stmt.where(AssetCatalog.generation == gen)
result = await db.execute(stmt) result = await db.execute(stmt)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def log_entry(self, db: AsyncSession, brand: str, model: str, specs: dict = None): async def log_entry(self, db: AsyncSession, brand: str, model: str, specs: dict):
"""Létrehoz vagy frissít egy katalógus bejegyzést.""" """Létrehoz vagy frissít egy bejegyzést az AssetCatalog-ban."""
existing = await self.check_exists(db, brand, model) existing = await self.check_exists(db, brand, model, specs.get("generation"))
if not existing: if not existing:
new_v = VehicleCatalog( new_v = AssetCatalog(
brand=brand, make=brand,
model=model, model=model,
category=self.category, generation=specs.get("generation"),
factory_specs=specs or {}, year_from=specs.get("year_from"),
verification_status="incomplete" if not specs else "verified" 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) db.add(new_v)
logger.info(f"🆕 Új katalógus elem: {brand} {model}")
return True return True
return False 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 asyncio
import logging
from datetime import datetime from datetime import datetime
# Frissített importok az új fájlnevekhez:
from .harvester_cars import CarHarvester from .harvester_cars import CarHarvester
from .harvester_bikes import BikeHarvester # Megjegyzés: Ellenőrizd, hogy a harvester_bikes/trucks fájlokban is BaseHarvester az alap!
from .harvester_trucks import TruckHarvester
logger = logging.getLogger(__name__)
class RobotManager: class RobotManager:
@staticmethod @staticmethod
async def run_full_sync(db): async def run_full_sync(db):
"""Sorban lefuttatja az összes robotot.""" """Sorban lefuttatja a robotokat az új AssetCatalog struktúrához."""
print(f"🕒 Szinkronizáció indítva: {datetime.now()}") logger.info(f"🕒 Teljes szinkronizáció indítva: {datetime.now()}")
robots = [ robots = [
CarHarvester(), CarHarvester(),
BikeHarvester(), # BikeHarvester(),
TruckHarvester() # TruckHarvester()
] ]
for robot in robots: for robot in robots:
try: try:
# Itt a robot lekéri az API-tól az ÖSSZES márkát és frissít
await robot.run(db) await robot.run(db)
logger.info(f"{robot.category} robot sikeresen lefutott.")
await asyncio.sleep(5) await asyncio.sleep(5)
except Exception as e: 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 @staticmethod
async def schedule_nightly_run(db): 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: while True:
now = datetime.now() now = datetime.now()
# Ha hajnali 2 és 2:01 között vagyunk, indítás
if now.hour == 2 and now.minute == 0: if now.hour == 2 and now.minute == 0:
await RobotManager.run_full_sync(db) 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(70)
await asyncio.sleep(30) # 30 másodpercenként ellenőrizzük az időt await asyncio.sleep(30)

Binary file not shown.

View File

@@ -8,28 +8,18 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context from alembic import context
# --- ÚTVONAL JAVÍTÁS --- # --- ÚTVONAL JAVÍTÁS ---
# Az aktuális fájl (env.py) helyéből kiindulva meghatározzuk a könyvtárakat sys.path.insert(0, "/app")
current_dir = os.path.dirname(os.path.realpath(__file__))
project_root = os.path.realpath(os.path.join(current_dir, '..'))
backend_dir = os.path.join(project_root, 'backend')
# Mindkét útvonalat betesszük a keresőbe, hogy a 'backend.app' és a sima 'app' is működjön
sys.path.insert(0, project_root)
sys.path.insert(0, backend_dir)
# Most már az Alembic megtalálja a konfigurációt és a modelleket
try: try:
from app.core.config import settings from app.core.config import settings
from app.db.base import Base from app.db.base import Base
from app.models import * # Fontos, hogy minden modell be legyen importálva! # Minden modellt importálunk a szinkronhoz
import app.models
except ImportError as e: except ImportError as e:
print(f"Hiba az importálásnál: {e}") print(f"Hiba az importálásnál: {e}")
print(f"Próbált útvonalak: {sys.path}")
raise raise
config = context.config config = context.config
# Dinamikus adatbázis URL a .env alapján (App User jelszavával)
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None: if config.config_file_name is not None:
@@ -37,37 +27,40 @@ if config.config_file_name is not None:
target_metadata = Base.metadata target_metadata = Base.metadata
# CSAK a 'data' sémával foglalkozunk!
def include_object(object, name, type_, reflected, compare_to):
if type_ == "table":
return object.schema == "data"
return True
def do_run_migrations(connection): def do_run_migrations(connection):
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
include_schemas=True, # Adatbázis sémák (pl. 'data') támogatása include_schemas=True,
version_table_schema='public' # Alembic tábla a public-ban marad include_object=include_object,
version_table_schema='public'
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
async def run_migrations_online() -> None: async def run_migrations_online() -> None:
"""Aszinkron kapcsolat felépítése és migráció futtatása"""
connectable = async_engine_from_config( connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}), config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.", prefix="sqlalchemy.",
poolclass=pool.NullPool, poolclass=pool.NullPool,
) )
async with connectable.connect() as connection: async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations) await connection.run_sync(do_run_migrations)
await connectable.dispose() await connectable.dispose()
if context.is_offline_mode(): if context.is_offline_mode():
url = config.get_main_option("sqlalchemy.url")
context.configure( context.configure(
url=url, url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
include_schemas=True include_schemas=True,
include_object=include_object
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()

View File

@@ -0,0 +1,554 @@
"""complete_sync
Revision ID: 0adbe75a0b3f
Revises:
Create Date: 2026-02-09 17:49:12.955967
"""
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 = '0adbe75a0b3f'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('badges',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=False),
sa.Column('icon_url', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'),
schema='data'
)
op.create_index(op.f('ix_data_badges_id'), 'badges', ['id'], unique=False, schema='data')
op.create_table('exchange_rates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('base_currency', sa.String(length=3), nullable=True),
sa.Column('target_currency', sa.String(length=3), nullable=True),
sa.Column('rate', sa.Numeric(precision=18, scale=6), nullable=True),
sa.Column('rate_date', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('geo_postal_codes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('country_code', sa.String(length=5), nullable=True),
sa.Column('zip_code', sa.String(length=10), nullable=False),
sa.Column('city', sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('geo_street_types',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'),
schema='data'
)
op.create_table('level_configs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('level_number', sa.Integer(), nullable=False),
sa.Column('min_points', sa.Integer(), nullable=False),
sa.Column('rank_name', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('level_number'),
schema='data'
)
op.create_index(op.f('ix_data_level_configs_id'), 'level_configs', ['id'], unique=False, schema='data')
op.create_table('point_rules',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('action_key', sa.String(), nullable=False),
sa.Column('points', sa.Integer(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_point_rules_action_key'), 'point_rules', ['action_key'], unique=True, schema='data')
op.create_index(op.f('ix_data_point_rules_id'), 'point_rules', ['id'], unique=False, schema='data')
op.create_table('regional_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('country_code', sa.String(), nullable=False),
sa.Column('currency', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('country_code'),
schema='data'
)
op.create_index(op.f('ix_data_regional_settings_id'), 'regional_settings', ['id'], unique=False, schema='data')
op.create_table('service_specialties',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('parent_id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('slug', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['parent_id'], ['data.service_specialties.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('slug'),
schema='data'
)
op.create_table('subscription_tiers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('rules', sa.JSON(), nullable=True),
sa.Column('is_custom', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'),
schema='data'
)
op.create_table('system_parameters',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(length=50), nullable=True),
sa.Column('value', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('description', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_system_parameters_id'), 'system_parameters', ['id'], unique=False, schema='data')
op.create_index(op.f('ix_data_system_parameters_key'), 'system_parameters', ['key'], unique=True, schema='data')
op.create_table('vehicle_catalog',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('make', sa.String(), nullable=False),
sa.Column('model', sa.String(), nullable=False),
sa.Column('generation', sa.String(), nullable=True),
sa.Column('year_from', sa.Integer(), nullable=True),
sa.Column('year_to', sa.Integer(), nullable=True),
sa.Column('vehicle_class', sa.String(), nullable=True),
sa.Column('fuel_type', sa.String(), nullable=True),
sa.Column('engine_code', sa.String(), nullable=True),
sa.Column('factory_data', sa.JSON(), server_default=sa.text("'{}'::jsonb"), nullable=True),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_vehicle_catalog_id'), 'vehicle_catalog', ['id'], unique=False, schema='data')
op.create_index(op.f('ix_data_vehicle_catalog_make'), 'vehicle_catalog', ['make'], unique=False, schema='data')
op.create_index(op.f('ix_data_vehicle_catalog_model'), 'vehicle_catalog', ['model'], unique=False, schema='data')
op.create_table('addresses',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('postal_code_id', sa.Integer(), nullable=True),
sa.Column('street_name', sa.String(length=200), nullable=False),
sa.Column('street_type', sa.String(length=50), nullable=False),
sa.Column('house_number', sa.String(length=50), nullable=False),
sa.Column('stairwell', sa.String(length=20), nullable=True),
sa.Column('floor', sa.String(length=20), nullable=True),
sa.Column('door', sa.String(length=20), nullable=True),
sa.Column('parcel_id', sa.String(length=50), nullable=True),
sa.Column('full_address_text', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['postal_code_id'], ['data.geo_postal_codes.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('assets',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('vin', sa.String(length=17), nullable=False),
sa.Column('license_plate', sa.String(length=20), nullable=True),
sa.Column('name', sa.String(), nullable=True),
sa.Column('year_of_manufacture', sa.Integer(), nullable=True),
sa.Column('catalog_id', sa.Integer(), nullable=True),
sa.Column('is_verified', sa.Boolean(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['catalog_id'], ['data.vehicle_catalog.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_assets_license_plate'), 'assets', ['license_plate'], unique=False, schema='data')
op.create_index(op.f('ix_data_assets_vin'), 'assets', ['vin'], unique=True, schema='data')
op.create_table('geo_streets',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('postal_code_id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.ForeignKeyConstraint(['postal_code_id'], ['data.geo_postal_codes.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('asset_events',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('asset_id', sa.UUID(), nullable=False),
sa.Column('event_type', sa.String(length=50), nullable=False),
sa.Column('recorded_mileage', sa.Integer(), nullable=True),
sa.Column('data', sa.JSON(), server_default=sa.text("'{}'::jsonb"), nullable=True),
sa.ForeignKeyConstraint(['asset_id'], ['data.assets.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('asset_financials',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('asset_id', sa.UUID(), nullable=True),
sa.Column('acquisition_price', sa.Numeric(precision=18, scale=2), nullable=True),
sa.Column('acquisition_date', sa.DateTime(), nullable=True),
sa.Column('financing_type', sa.String(), nullable=True),
sa.Column('residual_value_estimate', sa.Numeric(precision=18, scale=2), nullable=True),
sa.ForeignKeyConstraint(['asset_id'], ['data.assets.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('asset_id'),
schema='data'
)
op.create_table('asset_telemetry',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('asset_id', sa.UUID(), nullable=True),
sa.Column('current_mileage', sa.Integer(), nullable=True),
sa.Column('mileage_unit', sa.String(length=10), nullable=True),
sa.Column('vqi_score', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('dbs_score', sa.Numeric(precision=5, scale=2), nullable=True),
sa.ForeignKeyConstraint(['asset_id'], ['data.assets.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('asset_id'),
schema='data'
)
op.create_table('persons',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('id_uuid', sa.UUID(), nullable=False),
sa.Column('address_id', sa.UUID(), nullable=True),
sa.Column('last_name', sa.String(), nullable=False),
sa.Column('first_name', sa.String(), nullable=False),
sa.Column('mothers_last_name', sa.String(), nullable=True),
sa.Column('mothers_first_name', sa.String(), nullable=True),
sa.Column('birth_place', sa.String(), nullable=True),
sa.Column('birth_date', sa.DateTime(), nullable=True),
sa.Column('phone', sa.String(), nullable=True),
sa.Column('identity_docs', sa.JSON(), server_default=sa.text("'{}'::jsonb"), nullable=True),
sa.Column('medical_emergency', sa.JSON(), server_default=sa.text("'{}'::jsonb"), nullable=True),
sa.Column('ice_contact', sa.JSON(), server_default=sa.text("'{}'::jsonb"), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['address_id'], ['data.addresses.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id_uuid'),
schema='data'
)
op.create_index(op.f('ix_data_persons_id'), 'persons', ['id'], unique=False, schema='data')
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=True),
sa.Column('role', sa.Enum('admin', 'user', 'service', 'fleet_manager', 'driver', name='userrole'), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('region_code', sa.String(), nullable=True),
sa.Column('is_deleted', sa.Boolean(), nullable=True),
sa.Column('person_id', sa.BigInteger(), nullable=True),
sa.Column('preferred_language', sa.String(length=5), nullable=True),
sa.Column('preferred_currency', sa.String(length=3), nullable=True),
sa.Column('timezone', sa.String(length=50), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['person_id'], ['data.persons.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_users_email'), 'users', ['email'], unique=True, schema='data')
op.create_index(op.f('ix_data_users_id'), 'users', ['id'], unique=False, schema='data')
op.create_table('asset_reviews',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('asset_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('overall_rating', sa.Integer(), nullable=True),
sa.Column('criteria_scores', sa.JSON(), server_default=sa.text("'{}'::jsonb"), nullable=True),
sa.Column('comment', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['asset_id'], ['data.assets.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('audit_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('target_type', sa.String(), nullable=True),
sa.Column('target_id', sa.String(), nullable=True),
sa.Column('action', sa.String(), nullable=False),
sa.Column('changes', sa.JSON(), nullable=True),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_audit_logs_id'), 'audit_logs', ['id'], unique=False, schema='data')
op.create_index(op.f('ix_data_audit_logs_target_id'), 'audit_logs', ['target_id'], unique=False, schema='data')
op.create_index(op.f('ix_data_audit_logs_target_type'), 'audit_logs', ['target_type'], unique=False, schema='data')
op.create_table('documents',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('parent_type', sa.String(length=20), nullable=False),
sa.Column('parent_id', sa.String(length=50), nullable=False),
sa.Column('doc_type', sa.String(length=50), nullable=True),
sa.Column('original_name', sa.String(length=255), nullable=False),
sa.Column('file_hash', sa.String(length=64), nullable=False),
sa.Column('file_ext', sa.String(length=10), nullable=True),
sa.Column('mime_type', sa.String(length=100), nullable=True),
sa.Column('file_size', sa.Integer(), nullable=True),
sa.Column('has_thumbnail', sa.Boolean(), nullable=True),
sa.Column('thumbnail_path', sa.String(length=255), nullable=True),
sa.Column('uploaded_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['uploaded_by'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('organizations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('address_id', sa.UUID(), nullable=True),
sa.Column('full_name', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('display_name', sa.String(length=50), nullable=True),
sa.Column('default_currency', sa.String(length=3), nullable=True),
sa.Column('country_code', sa.String(length=2), nullable=True),
sa.Column('language', sa.String(length=5), nullable=True),
sa.Column('address_zip', sa.String(length=10), nullable=True),
sa.Column('address_city', sa.String(length=100), nullable=True),
sa.Column('address_street_name', sa.String(length=150), nullable=True),
sa.Column('address_street_type', sa.String(length=50), nullable=True),
sa.Column('address_house_number', sa.String(length=20), nullable=True),
sa.Column('address_hrsz', sa.String(length=50), nullable=True),
sa.Column('address_stairwell', sa.String(length=20), nullable=True),
sa.Column('address_floor', sa.String(length=20), nullable=True),
sa.Column('address_door', sa.String(length=20), nullable=True),
sa.Column('tax_number', sa.String(length=20), nullable=True),
sa.Column('reg_number', sa.String(length=50), nullable=True),
sa.Column('org_type', postgresql.ENUM('individual', 'service', 'service_provider', 'fleet_owner', 'club', 'business', name='orgtype', schema='data', inherit_schema=True), nullable=True),
sa.Column('status', sa.String(length=30), nullable=True),
sa.Column('is_deleted', sa.Boolean(), nullable=True),
sa.Column('notification_settings', sa.JSON(), server_default=sa.text('\'{ "notify_owner": true, "alert_days_before": [30, 15, 7, 1] }\'::jsonb'), nullable=True),
sa.Column('external_integration_config', sa.JSON(), server_default=sa.text("'{}'::jsonb"), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_transferable', sa.Boolean(), nullable=True),
sa.Column('is_verified', sa.Boolean(), nullable=True),
sa.Column('verification_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['address_id'], ['data.addresses.id'], ),
sa.ForeignKeyConstraint(['owner_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_organizations_id'), 'organizations', ['id'], unique=False, schema='data')
op.create_index(op.f('ix_data_organizations_tax_number'), 'organizations', ['tax_number'], unique=True, schema='data')
op.create_table('points_ledger',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('points', sa.Integer(), nullable=False),
sa.Column('reason', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_points_ledger_id'), 'points_ledger', ['id'], unique=False, schema='data')
op.create_table('ratings',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('author_id', sa.Integer(), nullable=False),
sa.Column('target_type', sa.String(length=20), nullable=False),
sa.Column('target_id', sa.UUID(), nullable=False),
sa.Column('score', sa.Integer(), nullable=False),
sa.Column('comment', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['author_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('user_badges',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('badge_id', sa.Integer(), nullable=False),
sa.Column('earned_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['badge_id'], ['data.badges.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_user_badges_id'), 'user_badges', ['id'], unique=False, schema='data')
op.create_table('user_stats',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('total_xp', sa.Integer(), nullable=False),
sa.Column('social_points', sa.Integer(), nullable=False),
sa.Column('current_level', sa.Integer(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('user_id'),
schema='data'
)
op.create_table('vehicle_ownerships',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vehicle_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('start_date', sa.Date(), nullable=False),
sa.Column('end_date', sa.Date(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ),
sa.ForeignKeyConstraint(['vehicle_id'], ['data.assets.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_vehicle_ownerships_id'), 'vehicle_ownerships', ['id'], unique=False, schema='data')
op.create_table('verification_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('token', sa.UUID(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token_type', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('is_used', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token'),
schema='data'
)
op.create_index(op.f('ix_data_verification_tokens_id'), 'verification_tokens', ['id'], unique=False, schema='data')
op.create_table('wallets',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('coin_balance', sa.Numeric(precision=18, scale=2), nullable=True),
sa.Column('credit_balance', sa.Numeric(precision=18, scale=2), nullable=True),
sa.Column('currency', sa.String(length=3), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id'),
schema='data'
)
op.create_index(op.f('ix_data_wallets_id'), 'wallets', ['id'], unique=False, schema='data')
op.create_table('asset_assignments',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('asset_id', sa.UUID(), nullable=False),
sa.Column('organization_id', sa.Integer(), nullable=False),
sa.Column('assigned_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('released_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('status', sa.String(length=30), nullable=True),
sa.ForeignKeyConstraint(['asset_id'], ['data.assets.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['data.organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('asset_costs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('asset_id', sa.UUID(), nullable=False),
sa.Column('organization_id', sa.Integer(), nullable=False),
sa.Column('driver_id', sa.Integer(), nullable=True),
sa.Column('cost_type', sa.String(length=50), nullable=False),
sa.Column('amount', sa.Numeric(precision=18, scale=2), nullable=False),
sa.Column('currency', sa.String(length=3), nullable=True),
sa.Column('net_amount', sa.Numeric(precision=18, scale=2), nullable=True),
sa.Column('vat_rate', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('exchange_rate_at_cost', sa.Numeric(precision=18, scale=6), nullable=True),
sa.Column('date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('mileage_at_cost', sa.Integer(), nullable=True),
sa.Column('data', sa.JSON(), server_default=sa.text("'{}'::jsonb"), nullable=True),
sa.ForeignKeyConstraint(['asset_id'], ['data.assets.id'], ),
sa.ForeignKeyConstraint(['driver_id'], ['data.users.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['data.organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('credit_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('org_id', sa.Integer(), nullable=True),
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['org_id'], ['data.organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('org_subscriptions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('org_id', sa.Integer(), nullable=True),
sa.Column('tier_id', sa.Integer(), nullable=True),
sa.Column('valid_from', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('valid_until', sa.DateTime(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['org_id'], ['data.organizations.id'], ),
sa.ForeignKeyConstraint(['tier_id'], ['data.subscription_tiers.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_table('organization_members',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('organization_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role', sa.String(), nullable=True),
sa.Column('permissions', sa.JSON(), server_default=sa.text("'{}'::jsonb"), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['data.organizations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_organization_members_id'), 'organization_members', ['id'], unique=False, schema='data')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_data_organization_members_id'), table_name='organization_members', schema='data')
op.drop_table('organization_members', schema='data')
op.drop_table('org_subscriptions', schema='data')
op.drop_table('credit_logs', schema='data')
op.drop_table('asset_costs', schema='data')
op.drop_table('asset_assignments', schema='data')
op.drop_index(op.f('ix_data_wallets_id'), table_name='wallets', schema='data')
op.drop_table('wallets', schema='data')
op.drop_index(op.f('ix_data_verification_tokens_id'), table_name='verification_tokens', schema='data')
op.drop_table('verification_tokens', schema='data')
op.drop_index(op.f('ix_data_vehicle_ownerships_id'), table_name='vehicle_ownerships', schema='data')
op.drop_table('vehicle_ownerships', schema='data')
op.drop_table('user_stats', schema='data')
op.drop_index(op.f('ix_data_user_badges_id'), table_name='user_badges', schema='data')
op.drop_table('user_badges', schema='data')
op.drop_table('ratings', schema='data')
op.drop_index(op.f('ix_data_points_ledger_id'), table_name='points_ledger', schema='data')
op.drop_table('points_ledger', schema='data')
op.drop_index(op.f('ix_data_organizations_tax_number'), table_name='organizations', schema='data')
op.drop_index(op.f('ix_data_organizations_id'), table_name='organizations', schema='data')
op.drop_table('organizations', schema='data')
op.drop_table('documents', schema='data')
op.drop_index(op.f('ix_data_audit_logs_target_type'), table_name='audit_logs', schema='data')
op.drop_index(op.f('ix_data_audit_logs_target_id'), table_name='audit_logs', schema='data')
op.drop_index(op.f('ix_data_audit_logs_id'), table_name='audit_logs', schema='data')
op.drop_table('audit_logs', schema='data')
op.drop_table('asset_reviews', schema='data')
op.drop_index(op.f('ix_data_users_id'), table_name='users', schema='data')
op.drop_index(op.f('ix_data_users_email'), table_name='users', schema='data')
op.drop_table('users', schema='data')
op.drop_index(op.f('ix_data_persons_id'), table_name='persons', schema='data')
op.drop_table('persons', schema='data')
op.drop_table('asset_telemetry', schema='data')
op.drop_table('asset_financials', schema='data')
op.drop_table('asset_events', schema='data')
op.drop_table('geo_streets', schema='data')
op.drop_index(op.f('ix_data_assets_vin'), table_name='assets', schema='data')
op.drop_index(op.f('ix_data_assets_license_plate'), table_name='assets', schema='data')
op.drop_table('assets', schema='data')
op.drop_table('addresses', schema='data')
op.drop_index(op.f('ix_data_vehicle_catalog_model'), table_name='vehicle_catalog', schema='data')
op.drop_index(op.f('ix_data_vehicle_catalog_make'), table_name='vehicle_catalog', schema='data')
op.drop_index(op.f('ix_data_vehicle_catalog_id'), table_name='vehicle_catalog', schema='data')
op.drop_table('vehicle_catalog', schema='data')
op.drop_index(op.f('ix_data_system_parameters_key'), table_name='system_parameters', schema='data')
op.drop_index(op.f('ix_data_system_parameters_id'), table_name='system_parameters', schema='data')
op.drop_table('system_parameters', schema='data')
op.drop_table('subscription_tiers', schema='data')
op.drop_table('service_specialties', schema='data')
op.drop_index(op.f('ix_data_regional_settings_id'), table_name='regional_settings', schema='data')
op.drop_table('regional_settings', schema='data')
op.drop_index(op.f('ix_data_point_rules_id'), table_name='point_rules', schema='data')
op.drop_index(op.f('ix_data_point_rules_action_key'), table_name='point_rules', schema='data')
op.drop_table('point_rules', schema='data')
op.drop_index(op.f('ix_data_level_configs_id'), table_name='level_configs', schema='data')
op.drop_table('level_configs', schema='data')
op.drop_table('geo_street_types', schema='data')
op.drop_table('geo_postal_codes', schema='data')
op.drop_table('exchange_rates', schema='data')
op.drop_index(op.f('ix_data_badges_id'), table_name='badges', schema='data')
op.drop_table('badges', schema='data')
# ### end Alembic commands ###

View File

@@ -0,0 +1,222 @@
"""fix_identity_scope_and_finalize_asset_costs
Revision ID: 2cfe9285eb9d
Revises: 0adbe75a0b3f
Create Date: 2026-02-10 09:47:16.879385
"""
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 = '2cfe9285eb9d'
down_revision: Union[str, Sequence[str], None] = '0adbe75a0b3f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('addresses_postal_code_id_fkey'), 'addresses', type_='foreignkey')
op.create_foreign_key(None, 'addresses', 'geo_postal_codes', ['postal_code_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('asset_assignments_asset_id_fkey'), 'asset_assignments', type_='foreignkey')
op.drop_constraint(op.f('asset_assignments_organization_id_fkey'), 'asset_assignments', type_='foreignkey')
op.create_foreign_key(None, 'asset_assignments', 'assets', ['asset_id'], ['id'], source_schema='data', referent_schema='data')
op.create_foreign_key(None, 'asset_assignments', 'organizations', ['organization_id'], ['id'], source_schema='data', referent_schema='data')
op.add_column('asset_costs', sa.Column('amount_local', sa.Numeric(precision=18, scale=2), nullable=False))
op.add_column('asset_costs', sa.Column('currency_local', sa.String(length=3), nullable=False))
op.add_column('asset_costs', sa.Column('amount_eur', sa.Numeric(precision=18, scale=2), nullable=True))
op.add_column('asset_costs', sa.Column('net_amount_local', sa.Numeric(precision=18, scale=2), nullable=True))
op.add_column('asset_costs', sa.Column('exchange_rate_used', sa.Numeric(precision=18, scale=6), nullable=True))
op.drop_constraint(op.f('asset_costs_asset_id_fkey'), 'asset_costs', type_='foreignkey')
op.drop_constraint(op.f('asset_costs_organization_id_fkey'), 'asset_costs', type_='foreignkey')
op.drop_constraint(op.f('asset_costs_driver_id_fkey'), 'asset_costs', type_='foreignkey')
op.create_foreign_key(None, 'asset_costs', 'assets', ['asset_id'], ['id'], source_schema='data', referent_schema='data')
op.create_foreign_key(None, 'asset_costs', 'organizations', ['organization_id'], ['id'], source_schema='data', referent_schema='data')
op.create_foreign_key(None, 'asset_costs', 'users', ['driver_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_column('asset_costs', 'currency')
op.drop_column('asset_costs', 'amount')
op.drop_column('asset_costs', 'exchange_rate_at_cost')
op.drop_column('asset_costs', 'net_amount')
op.drop_constraint(op.f('asset_events_asset_id_fkey'), 'asset_events', type_='foreignkey')
op.create_foreign_key(None, 'asset_events', 'assets', ['asset_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('asset_financials_asset_id_fkey'), 'asset_financials', type_='foreignkey')
op.create_foreign_key(None, 'asset_financials', 'assets', ['asset_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('asset_reviews_user_id_fkey'), 'asset_reviews', type_='foreignkey')
op.drop_constraint(op.f('asset_reviews_asset_id_fkey'), 'asset_reviews', type_='foreignkey')
op.create_foreign_key(None, 'asset_reviews', 'users', ['user_id'], ['id'], source_schema='data', referent_schema='data')
op.create_foreign_key(None, 'asset_reviews', 'assets', ['asset_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('asset_telemetry_asset_id_fkey'), 'asset_telemetry', type_='foreignkey')
op.create_foreign_key(None, 'asset_telemetry', 'assets', ['asset_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('assets_catalog_id_fkey'), 'assets', type_='foreignkey')
op.create_foreign_key(None, 'assets', 'vehicle_catalog', ['catalog_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('audit_logs_user_id_fkey'), 'audit_logs', type_='foreignkey')
op.create_foreign_key(None, 'audit_logs', 'users', ['user_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('credit_logs_org_id_fkey'), 'credit_logs', type_='foreignkey')
op.create_foreign_key(None, 'credit_logs', 'organizations', ['org_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('documents_uploaded_by_fkey'), 'documents', type_='foreignkey')
op.create_foreign_key(None, 'documents', 'users', ['uploaded_by'], ['id'], source_schema='data', referent_schema='data')
op.alter_column('exchange_rates', 'rate',
existing_type=sa.NUMERIC(precision=18, scale=6),
nullable=False)
op.create_unique_constraint(None, 'exchange_rates', ['target_currency'], schema='data')
op.drop_constraint(op.f('geo_streets_postal_code_id_fkey'), 'geo_streets', type_='foreignkey')
op.create_foreign_key(None, 'geo_streets', 'geo_postal_codes', ['postal_code_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('org_subscriptions_org_id_fkey'), 'org_subscriptions', type_='foreignkey')
op.drop_constraint(op.f('org_subscriptions_tier_id_fkey'), 'org_subscriptions', type_='foreignkey')
op.create_foreign_key(None, 'org_subscriptions', 'subscription_tiers', ['tier_id'], ['id'], source_schema='data', referent_schema='data')
op.create_foreign_key(None, 'org_subscriptions', 'organizations', ['org_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('organization_members_organization_id_fkey'), 'organization_members', type_='foreignkey')
op.drop_constraint(op.f('organization_members_user_id_fkey'), 'organization_members', type_='foreignkey')
op.create_foreign_key(None, 'organization_members', 'organizations', ['organization_id'], ['id'], source_schema='data', referent_schema='data')
op.create_foreign_key(None, 'organization_members', 'users', ['user_id'], ['id'], source_schema='data', referent_schema='data')
op.alter_column('organizations', 'org_type',
existing_type=postgresql.ENUM('individual', 'service', 'service_provider', 'fleet_owner', 'club', 'business', name='orgtype'),
type_=postgresql.ENUM('individual', 'service', 'service_provider', 'fleet_owner', 'club', 'business', name='orgtype', schema='data', inherit_schema=True),
existing_nullable=True)
op.drop_constraint(op.f('organizations_address_id_fkey'), 'organizations', type_='foreignkey')
op.drop_constraint(op.f('organizations_owner_id_fkey'), 'organizations', type_='foreignkey')
op.create_foreign_key(None, 'organizations', 'users', ['owner_id'], ['id'], source_schema='data', referent_schema='data')
op.create_foreign_key(None, 'organizations', 'addresses', ['address_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('persons_address_id_fkey'), 'persons', type_='foreignkey')
op.create_foreign_key(None, 'persons', 'addresses', ['address_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('points_ledger_user_id_fkey'), 'points_ledger', type_='foreignkey')
op.create_foreign_key(None, 'points_ledger', 'users', ['user_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('ratings_author_id_fkey'), 'ratings', type_='foreignkey')
op.create_foreign_key(None, 'ratings', 'users', ['author_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('service_specialties_parent_id_fkey'), 'service_specialties', type_='foreignkey')
op.create_foreign_key(None, 'service_specialties', 'service_specialties', ['parent_id'], ['id'], source_schema='data', referent_schema='data')
op.alter_column('system_parameters', 'key',
existing_type=sa.VARCHAR(length=50),
nullable=False)
op.alter_column('system_parameters', 'value',
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False)
op.drop_constraint(op.f('user_badges_badge_id_fkey'), 'user_badges', type_='foreignkey')
op.drop_constraint(op.f('user_badges_user_id_fkey'), 'user_badges', type_='foreignkey')
op.create_foreign_key(None, 'user_badges', 'badges', ['badge_id'], ['id'], source_schema='data', referent_schema='data')
op.create_foreign_key(None, 'user_badges', 'users', ['user_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('user_stats_user_id_fkey'), 'user_stats', type_='foreignkey')
op.create_foreign_key(None, 'user_stats', 'users', ['user_id'], ['id'], source_schema='data', referent_schema='data')
op.alter_column('users', 'custom_permissions',
existing_type=postgresql.JSONB(astext_type=sa.Text()),
type_=sa.JSON(),
existing_nullable=True,
existing_server_default=sa.text("'{}'::jsonb"))
op.drop_constraint(op.f('users_person_id_fkey'), 'users', type_='foreignkey')
op.create_foreign_key(None, 'users', 'persons', ['person_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('vehicle_ownerships_user_id_fkey'), 'vehicle_ownerships', type_='foreignkey')
op.drop_constraint(op.f('vehicle_ownerships_vehicle_id_fkey'), 'vehicle_ownerships', type_='foreignkey')
op.create_foreign_key(None, 'vehicle_ownerships', 'users', ['user_id'], ['id'], source_schema='data', referent_schema='data')
op.create_foreign_key(None, 'vehicle_ownerships', 'assets', ['vehicle_id'], ['id'], source_schema='data', referent_schema='data')
op.drop_constraint(op.f('verification_tokens_user_id_fkey'), 'verification_tokens', type_='foreignkey')
op.create_foreign_key(None, 'verification_tokens', 'users', ['user_id'], ['id'], source_schema='data', referent_schema='data', ondelete='CASCADE')
op.drop_constraint(op.f('wallets_user_id_fkey'), 'wallets', type_='foreignkey')
op.create_foreign_key(None, 'wallets', 'users', ['user_id'], ['id'], source_schema='data', referent_schema='data')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'wallets', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('wallets_user_id_fkey'), 'wallets', 'users', ['user_id'], ['id'])
op.drop_constraint(None, 'verification_tokens', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('verification_tokens_user_id_fkey'), 'verification_tokens', 'users', ['user_id'], ['id'], ondelete='CASCADE')
op.drop_constraint(None, 'vehicle_ownerships', schema='data', type_='foreignkey')
op.drop_constraint(None, 'vehicle_ownerships', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('vehicle_ownerships_vehicle_id_fkey'), 'vehicle_ownerships', 'assets', ['vehicle_id'], ['id'])
op.create_foreign_key(op.f('vehicle_ownerships_user_id_fkey'), 'vehicle_ownerships', 'users', ['user_id'], ['id'])
op.drop_constraint(None, 'users', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('users_person_id_fkey'), 'users', 'persons', ['person_id'], ['id'])
op.alter_column('users', 'custom_permissions',
existing_type=sa.JSON(),
type_=postgresql.JSONB(astext_type=sa.Text()),
existing_nullable=True,
existing_server_default=sa.text("'{}'::jsonb"))
op.drop_constraint(None, 'user_stats', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('user_stats_user_id_fkey'), 'user_stats', 'users', ['user_id'], ['id'])
op.drop_constraint(None, 'user_badges', schema='data', type_='foreignkey')
op.drop_constraint(None, 'user_badges', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('user_badges_user_id_fkey'), 'user_badges', 'users', ['user_id'], ['id'])
op.create_foreign_key(op.f('user_badges_badge_id_fkey'), 'user_badges', 'badges', ['badge_id'], ['id'])
op.alter_column('system_parameters', 'value',
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True)
op.alter_column('system_parameters', 'key',
existing_type=sa.VARCHAR(length=50),
nullable=True)
op.drop_constraint(None, 'service_specialties', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('service_specialties_parent_id_fkey'), 'service_specialties', 'service_specialties', ['parent_id'], ['id'])
op.drop_constraint(None, 'ratings', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('ratings_author_id_fkey'), 'ratings', 'users', ['author_id'], ['id'])
op.drop_constraint(None, 'points_ledger', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('points_ledger_user_id_fkey'), 'points_ledger', 'users', ['user_id'], ['id'])
op.drop_constraint(None, 'persons', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('persons_address_id_fkey'), 'persons', 'addresses', ['address_id'], ['id'])
op.drop_constraint(None, 'organizations', schema='data', type_='foreignkey')
op.drop_constraint(None, 'organizations', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('organizations_owner_id_fkey'), 'organizations', 'users', ['owner_id'], ['id'])
op.create_foreign_key(op.f('organizations_address_id_fkey'), 'organizations', 'addresses', ['address_id'], ['id'])
op.alter_column('organizations', 'org_type',
existing_type=postgresql.ENUM('individual', 'service', 'service_provider', 'fleet_owner', 'club', 'business', name='orgtype', schema='data', inherit_schema=True),
type_=postgresql.ENUM('individual', 'service', 'service_provider', 'fleet_owner', 'club', 'business', name='orgtype'),
existing_nullable=True)
op.drop_constraint(None, 'organization_members', schema='data', type_='foreignkey')
op.drop_constraint(None, 'organization_members', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('organization_members_user_id_fkey'), 'organization_members', 'users', ['user_id'], ['id'])
op.create_foreign_key(op.f('organization_members_organization_id_fkey'), 'organization_members', 'organizations', ['organization_id'], ['id'])
op.drop_constraint(None, 'org_subscriptions', schema='data', type_='foreignkey')
op.drop_constraint(None, 'org_subscriptions', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('org_subscriptions_tier_id_fkey'), 'org_subscriptions', 'subscription_tiers', ['tier_id'], ['id'])
op.create_foreign_key(op.f('org_subscriptions_org_id_fkey'), 'org_subscriptions', 'organizations', ['org_id'], ['id'])
op.drop_constraint(None, 'geo_streets', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('geo_streets_postal_code_id_fkey'), 'geo_streets', 'geo_postal_codes', ['postal_code_id'], ['id'])
op.drop_constraint(None, 'exchange_rates', schema='data', type_='unique')
op.alter_column('exchange_rates', 'rate',
existing_type=sa.NUMERIC(precision=18, scale=6),
nullable=True)
op.drop_constraint(None, 'documents', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('documents_uploaded_by_fkey'), 'documents', 'users', ['uploaded_by'], ['id'])
op.drop_constraint(None, 'credit_logs', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('credit_logs_org_id_fkey'), 'credit_logs', 'organizations', ['org_id'], ['id'])
op.drop_constraint(None, 'audit_logs', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('audit_logs_user_id_fkey'), 'audit_logs', 'users', ['user_id'], ['id'])
op.drop_constraint(None, 'assets', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('assets_catalog_id_fkey'), 'assets', 'vehicle_catalog', ['catalog_id'], ['id'])
op.drop_constraint(None, 'asset_telemetry', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('asset_telemetry_asset_id_fkey'), 'asset_telemetry', 'assets', ['asset_id'], ['id'])
op.drop_constraint(None, 'asset_reviews', schema='data', type_='foreignkey')
op.drop_constraint(None, 'asset_reviews', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('asset_reviews_asset_id_fkey'), 'asset_reviews', 'assets', ['asset_id'], ['id'])
op.create_foreign_key(op.f('asset_reviews_user_id_fkey'), 'asset_reviews', 'users', ['user_id'], ['id'])
op.drop_constraint(None, 'asset_financials', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('asset_financials_asset_id_fkey'), 'asset_financials', 'assets', ['asset_id'], ['id'])
op.drop_constraint(None, 'asset_events', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('asset_events_asset_id_fkey'), 'asset_events', 'assets', ['asset_id'], ['id'])
op.add_column('asset_costs', sa.Column('net_amount', sa.NUMERIC(precision=18, scale=2), autoincrement=False, nullable=True))
op.add_column('asset_costs', sa.Column('exchange_rate_at_cost', sa.NUMERIC(precision=18, scale=6), autoincrement=False, nullable=True))
op.add_column('asset_costs', sa.Column('amount', sa.NUMERIC(precision=18, scale=2), autoincrement=False, nullable=False))
op.add_column('asset_costs', sa.Column('currency', sa.VARCHAR(length=3), autoincrement=False, nullable=True))
op.drop_constraint(None, 'asset_costs', schema='data', type_='foreignkey')
op.drop_constraint(None, 'asset_costs', schema='data', type_='foreignkey')
op.drop_constraint(None, 'asset_costs', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('asset_costs_driver_id_fkey'), 'asset_costs', 'users', ['driver_id'], ['id'])
op.create_foreign_key(op.f('asset_costs_organization_id_fkey'), 'asset_costs', 'organizations', ['organization_id'], ['id'])
op.create_foreign_key(op.f('asset_costs_asset_id_fkey'), 'asset_costs', 'assets', ['asset_id'], ['id'])
op.drop_column('asset_costs', 'exchange_rate_used')
op.drop_column('asset_costs', 'net_amount_local')
op.drop_column('asset_costs', 'amount_eur')
op.drop_column('asset_costs', 'currency_local')
op.drop_column('asset_costs', 'amount_local')
op.drop_constraint(None, 'asset_assignments', schema='data', type_='foreignkey')
op.drop_constraint(None, 'asset_assignments', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('asset_assignments_organization_id_fkey'), 'asset_assignments', 'organizations', ['organization_id'], ['id'])
op.create_foreign_key(op.f('asset_assignments_asset_id_fkey'), 'asset_assignments', 'assets', ['asset_id'], ['id'])
op.drop_constraint(None, 'addresses', schema='data', type_='foreignkey')
op.create_foreign_key(op.f('addresses_postal_code_id_fkey'), 'addresses', 'geo_postal_codes', ['postal_code_id'], ['id'])
# ### end Alembic commands ###

View File

@@ -1,152 +0,0 @@
"""Clean gamification setup
Revision ID: c21c2c7e70d4
Revises:
Create Date: 2026-01-24 11:19:10.464212
"""
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 = 'c21c2c7e70d4'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('target_type', sa.String(), nullable=True),
sa.Column('target_id', sa.Integer(), nullable=True),
sa.Column('action', sa.String(), nullable=False),
sa.Column('changes', sa.JSON(), nullable=True),
sa.Column('ip_address', sa.String(), nullable=True),
sa.Column('user_agent', sa.String(), nullable=True),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_audit_logs_id'), 'audit_logs', ['id'], unique=False, schema='data')
op.create_index(op.f('ix_data_audit_logs_target_id'), 'audit_logs', ['target_id'], unique=False, schema='data')
op.create_index(op.f('ix_data_audit_logs_target_type'), 'audit_logs', ['target_type'], unique=False, schema='data')
op.create_table('companies',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('tax_number', sa.String(), nullable=True),
sa.Column('subscription_tier', sa.String(), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_companies_id'), 'companies', ['id'], unique=False, schema='data')
op.create_table('company_members',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('company_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role', sa.Enum('OWNER', 'MANAGER', 'DRIVER', name='companyrole'), nullable=False),
sa.Column('can_edit_service', sa.Boolean(), nullable=True),
sa.Column('can_see_costs', sa.Boolean(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['company_id'], ['data.companies.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_company_members_id'), 'company_members', ['id'], unique=False, schema='data')
op.create_table('vehicle_assignments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('company_id', sa.Integer(), nullable=False),
sa.Column('vehicle_id', sa.Integer(), nullable=False),
sa.Column('driver_id', sa.Integer(), nullable=False),
sa.Column('start_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('end_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('start_odometer', sa.Integer(), nullable=True),
sa.Column('end_odometer', sa.Integer(), nullable=True),
sa.Column('notes', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['company_id'], ['data.companies.id'], ),
sa.ForeignKeyConstraint(['driver_id'], ['data.users.id'], ),
sa.ForeignKeyConstraint(['vehicle_id'], ['data.vehicles.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_vehicle_assignments_id'), 'vehicle_assignments', ['id'], unique=False, schema='data')
op.create_table('vehicle_ownerships',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vehicle_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('start_date', sa.Date(), nullable=False),
sa.Column('end_date', sa.Date(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], ),
sa.ForeignKeyConstraint(['vehicle_id'], ['data.vehicles.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='data'
)
op.create_index(op.f('ix_data_vehicle_ownerships_id'), 'vehicle_ownerships', ['id'], unique=False, schema='data')
# op.drop_table('costs', schema='data')
# op.drop_table('alembic_version')
# op.drop_table('vehicle_history', schema='data')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('vehicle_history',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('vehicle_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('role', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('start_date', sa.DATE(), autoincrement=False, nullable=False),
sa.Column('end_date', sa.DATE(), autoincrement=False, nullable=True),
sa.Column('start_mileage', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('end_mileage', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), sa.Computed('(end_date IS NULL)', persisted=True), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], name=op.f('vehicle_history_user_id_fkey')),
sa.ForeignKeyConstraint(['vehicle_id'], ['data.vehicles.id'], name=op.f('vehicle_history_vehicle_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('vehicle_history_pkey')),
schema='data'
)
op.create_table('alembic_version',
sa.Column('version_num', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('version_num', name=op.f('alembic_version_pkc'))
)
op.create_table('costs',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('vehicle_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('cost_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('amount', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
sa.Column('date', sa.DATE(), autoincrement=False, nullable=False),
sa.Column('mileage_at_cost', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('document_url', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['data.users.id'], name=op.f('costs_user_id_fkey')),
sa.ForeignKeyConstraint(['vehicle_id'], ['data.vehicles.id'], name=op.f('costs_vehicle_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('costs_pkey')),
schema='data'
)
op.drop_index(op.f('ix_data_vehicle_ownerships_id'), table_name='vehicle_ownerships', schema='data')
op.drop_table('vehicle_ownerships', schema='data')
op.drop_index(op.f('ix_data_vehicle_assignments_id'), table_name='vehicle_assignments', schema='data')
op.drop_table('vehicle_assignments', schema='data')
op.drop_index(op.f('ix_data_company_members_id'), table_name='company_members', schema='data')
op.drop_table('company_members', schema='data')
op.drop_index(op.f('ix_data_companies_id'), table_name='companies', schema='data')
op.drop_table('companies', schema='data')
op.drop_index(op.f('ix_data_audit_logs_target_type'), table_name='audit_logs', schema='data')
op.drop_index(op.f('ix_data_audit_logs_target_id'), table_name='audit_logs', schema='data')
op.drop_index(op.f('ix_data_audit_logs_id'), table_name='audit_logs', schema='data')
op.drop_table('audit_logs', schema='data')
# ### end Alembic commands ###

View File

@@ -1,3 +1,5 @@
version: '3.8'
services: services:
# 1. MIGRÁCIÓ (Adatbázis szerkezet frissítése) # 1. MIGRÁCIÓ (Adatbázis szerkezet frissítése)
migrate: migrate:
@@ -7,9 +9,7 @@ services:
container_name: service_finder_migrate container_name: service_finder_migrate
env_file: .env env_file: .env
volumes: volumes:
- ./backend:/app - ./backend:/app # Ez tartalmazza az alembic.ini-t és a migrations mappát is!
- ./alembic.ini:/app/alembic.ini
- ./migrations:/app/migrations
environment: environment:
PYTHONPATH: /app PYTHONPATH: /app
DATABASE_URL: ${MIGRATION_DATABASE_URL} DATABASE_URL: ${MIGRATION_DATABASE_URL}
@@ -28,10 +28,8 @@ services:
env_file: .env env_file: .env
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./alembic.ini:/app/alembic.ini
- ./migrations:/app/migrations
- /mnt/nas/app_data:/mnt/nas/app_data # Központi NAS elérés - /mnt/nas/app_data:/mnt/nas/app_data # Központi NAS elérés
- ./static_previews:/app/static/previews # Lokális SSD gyorsítótár a miniképeknek - ./static_previews:/app/static/previews # Lokális SSD gyorsítótár
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips="*" command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips="*"
ports: ports:
- "8000:8000" - "8000:8000"
@@ -72,7 +70,7 @@ services:
- default - default
restart: unless-stopped restart: unless-stopped
# 4. REDIS (Lokális cache, NAS perzisztencia) # 4. REDIS (Lokális cache)
redis: redis:
image: redis:alpine image: redis:alpine
container_name: service_finder_redis container_name: service_finder_redis

View File

@@ -87,4 +87,18 @@ Minden hónap végén az első 3 helyezett extra Kreditet vagy "Voucher"-t kap,
- **Célcsoport**: Kizárólag természetes személyek (`role: user`, `driver`). - **Célcsoport**: Kizárólag természetes személyek (`role: user`, `driver`).
- **Kizárások**: Szervezetek (Organizations) és Adminisztrátorok nem gyűjtenek XP-t. - **Kizárások**: Szervezetek (Organizations) és Adminisztrátorok nem gyűjtenek XP-t.
- **Logika**: Minden `PointsLedger` bejegyzés kötelezően hivatkozik egy `user_id`-ra. - **Logika**: Minden `PointsLedger` bejegyzés kötelezően hivatkozik egy `user_id`-ra.
- **Mezőnevek**: Adatbázis szinten a pontok az `id`, `user_id`, `points`, `reason` mezőkben tárolódnak. - **Mezőnevek**: Adatbázis szinten a pontok az `id`, `user_id`, `points`, `reason` mezőkben tárolódnak.
## 2026.02.10 FRISSÍTÉS - GAMIFICATION ÖKOSZISZTÉMA
### 1. Pontrendszer Logika
A rendszer különválasztja a tekintélyt és a jutalmat:
- **XP (Tapasztalat):** Végleges szintlépéshez. Képlet: $BaseXP \times Level^{1.5}$. (Nehezedő görbe).
- **Social Points (Szezonális):** Időszakos versenyekhez (pl. Hónap Vadásza).
- **Kredit:** Fizetőeszköz, amit Social Pontokból lehet váltani (pl. 1000 pont = 100 Kredit).
### 2. Konfiguráció
Minden érték (szorzók, határok) a \`GAMIFICATION_MASTER_CONFIG\` JSON paraméterben állítható Admin felületről, kódmódosítás nélkül.
### 3. Audit
Minden pontmozgás a \`PointsLedger\` táblába kerül rögzítésre a visszakövethetőség érdekében.

View File

@@ -209,4 +209,44 @@ A fejlesztések rendben tartásához javaslom a **`17_DEVELOPER_NOTES_AND_PITFAL
- **SQLAlchemy hiba**: Javítva az `IndexError` a katalógus lekérdezésnél és az import hiba a `PG_UUID` kapcsán. - **SQLAlchemy hiba**: Javítva az `IndexError` a katalógus lekérdezésnél és az import hiba a `PG_UUID` kapcsán.
### Megjegyzés ### Megjegyzés
A rendszer most már képes egyetlen KYC folyamat alatt aktiválni a felhasználót, létrehozni az egyéni flottáját, inicializálni a pénztárcáját (Kredit/Coin) és rögzíteni az első járművét kezdő km-óra állással. A rendszer most már képes egyetlen KYC folyamat alatt aktiválni a felhasználót, létrehozni az egyéni flottáját, inicializálni a pénztárcáját (Kredit/Coin) és rögzíteni az első járművét kezdő km-óra állással.
## [Unreleased] - 2026-02-10
### 🚀 Added (Új funkciók)
- **RBAC System:**
- \`User\` tábla bővítése: \`scope_level\`, \`scope_id\`, \`custom_permissions\`.
- \`system_parameters\` tábla létrehozása a globális JSON konfigurációkhoz.
- Master RBAC JSON konfiguráció seedelése.
- **Gamification:**
- \`GamificationService\` létrehozása (XP, Level, Credit logika).
- Automata pontszámítás és nehezedő szintek logikája.
- **API Modularitás:**
- \`assets.py\` szétbontása 3 végpontra (Identity, Costs, Telemetry).
### 🛠 Changed (Módosítások)
- **Asset Model:** Az \`Asset\` entitás mostantól lazy loading helyett \`selectinload\` stratégiát használ a teljesítmény érdekében.
- **Error Handling:** Javítottuk az \`asyncpg\` többszörös parancs-futtatási hibáját a setup scriptekben.
- **Configuration:** A rendszerbeállítások mostantól adatbázis-alapúak (JSONB) a hardcoded konstansok helyett.
### 🐛 Fixed (Javítások)
- **Schema Mismatch:** SQLAlchemy modellek és Pydantic sémák szétválasztása (`models/asset.py` vs `schemas/asset.py`).
- **Data Integrity:** \`updated_at\` és \`is_active\` oszlopok pótlása a \`system_parameters\` táblában.
- **API Stability:** \`getattr\` használata a hiányzó opcionális mezőknél (pl. \`net_amount\`), hogy ne szálljon el az API 500-as hibával.
## [1.2.0] - 2026-02-10
### Added
- **Asset Financials 2.0**: Pivot-Currency modell implementálva (helyi deviza + EUR párhuzamos tárolás).
- **Smart Auth Token**: JWT token mostantól tartalmazza a `rank`, `scope_level` és `scope_id` mezőket a gyors jogosultságkezeléshez.
- **CostService**: Automatikus árfolyam-kalkuláció, telemetria-szinkron és XP jóváírás költségrögzítéskor.
- **ExchangeRate**: Új árfolyamtábla és modell az EUR alapú váltásokhoz.
### Fixed
- **Circular Import Resolution**: Megszüntetve a `db.base` és a `models` közötti körkörös függőség az import lánc modularizálásával.
- **Alembic Identity Sync**: Visszaállítva a `User` modell hiányzó `scope_level` és `custom_permissions` mezői, megakadályozva az adatvesztést migrációkor.
- **NotNullViolationError**: Fixálva az `asset_costs` tábla migrációja (amount_local NOT NULL kényszer).
### Changed
- `AssetCost` modell mezőnevek szinkronizálva a pénzügyi standardokhoz (`amount_local`, `amount_eur`).
- `SystemParameter` modell elnevezés igazítva a meglévő adatbázis sémához.

View File

@@ -186,4 +186,19 @@ A járműadatok kezelése hibrid módon történik.
### 5.2 Költségkövetés (TCO) ### 5.2 Költségkövetés (TCO)
- Minden Asset-hez rögzíthető költség (`asset_costs`). - Minden Asset-hez rögzíthető költség (`asset_costs`).
- Kötelező adatok: Kategória, Összeg, Dátum. - Kötelező adatok: Kategória, Összeg, Dátum.
- Opcionális: Km óra állása (az amortizáció és szervizintervallum számításához). - Opcionális: Km óra állása (az amortizáció és szervizintervallum számításához).
## 2026.02.10 FRISSÍTÉS - ATOMIZÁLT ADATMODELL ÉS MODULÁRIS API
### 1. Adatbázis Szerkezet (A 4 Pillér)
A járművek kezelése "Single Responsibility" elv alapján 4 modulra bomlott:
1. **Identity (Asset):** Alapadatok (VIN, Rendszám, Tulajdonos).
2. **Catalog (AssetCatalog):** Gyári statikus adatok (Típus, Motor, Akku). Ezt a Robotok töltik.
3. **Telemetry (AssetTelemetry):** Változó állapot (KM óra, VQI minőség index, DBS vezetési stílus).
4. **Financials (AssetCost):** Pénzügyi tranzakciók 9 kategóriába sorolva (Fuel, Service, Tax, stb.).
### 2. Moduláris API Végpontok
A teljesítmény optimalizálása érdekében a \`Full Profile\` helyett 3 dedikált végpontot használunk:
- \`GET /api/v1/assets/{id}\`: Csak identitás és katalógus (Gyors nézet).
- \`GET /api/v1/assets/{id}/costs\`: Csak pénzügyi történet és grafikonok.
- \`GET /api/v1/assets/{id}/telemetry\`: Csak élő adatok (Dashboard).

View File

@@ -86,4 +86,20 @@ A rendszer minden fontos paramétere a `data.system_settings` táblában tárolt
### 6.2 Paraméterezhető modulok ### 6.2 Paraméterezhető modulok
* **Service Hunt:** Távolságok, XP/Kredit szorzók. * **Service Hunt:** Távolságok, XP/Kredit szorzók.
* **Fraud Protection:** Strike limitek, kitiltási idők. * **Fraud Protection:** Strike limitek, kitiltási idők.
* **Billing:** EUR/HUF/USD váltószámok, csomagárak, jármű-slot árak. * **Billing:** EUR/HUF/USD váltószámok, csomagárak, jármű-slot árak.
## 2026.02.10 FRISSÍTÉS - HIERARCHIKUS RBAC RENDSZER
### 1. Rang-alapú Jogosultság (Rank System)
A rendszer a \`system_parameters\` táblában tárolt \`RBAC_MASTER_CONFIG\` JSON alapján működik.
- **SUPERADMIN (Rank 100):** Globális hatókör, mindent lát.
- **COUNTRY_ADMIN (Rank 80):** Országos felelős.
- **REGION_ADMIN (Rank 60):** Területi vezető (Manage Moderators).
- **MODERATOR (Rank 40):** Adatvalidátor.
- **SALES (Rank 20):** Üzletkötő (Csak saját partnerek).
- **USER (Rank 10):** Végfelhasználó.
### 2. Scope (Hatókör) Védelem
Minden műveletnél ellenőrizzük a \`scope_id\` egyezését:
- Ha a felhasználó \`scope_level = 'region'\`, akkor csak olyan adatot szerkeszthet, ami ugyanahhoz a régióhoz tartozik.
- Kivétel: Impersonation (Megszemélyesítés) - Audit loggal védve.

View File

@@ -1,3 +1,135 @@
🧬 SERVICE FINDER - UNIVERSAL SYSTEM PROMPT (v1.2)
ROLE: Senior Technical Product Manager & Lead System Architect PROJECT: Service Finder - Traffic Ecosystem SuperApp SOURCE OF TRUTH: Grand Master Book (0019) + 2026.02.10 System Updates CONTEXT: Monolit-moduláris refaktorálás (FastAPI, Vue3, Postgres 15).
1. VÍZIÓ ÉS KONTEXTUS (00, 01)
Nem egy egyszerű nyilvántartó rendszert építünk, hanem egy Digital Twin (Digitális Iker) alapú ökoszisztémát.
Core Philosophy: "A jármű örök, a tulajdonos vándor." (Vehicle-Centric Architecture).
Pillére:
Core Fleet: Életút és TCO követés.
Marketplace: Szervizkereső és időpontfoglalás.
Trust Engine: Bizonyíték alapú előélet (OCR, Fotó).
Economy: Kredit és Gamification.
2. TECHNOLÓGIAI STACK ÉS INFRA (02, 03, 04, 08)
Frontend: Vue 3 (Composition API) + Vite + Tailwind CSS + Pinia. Dumb Frontend elv.
Backend: Python 3.12 + FastAPI. Szigorú Pydantic validáció.
Adatbázis: PostgreSQL 15. Két séma: data (üzleti), public (rendszer).
Storage: MinIO (S3 kompatibilis) titkosított dokumentumokhoz.
Hálózat: Internal Net (shared_db_net) zárt. Public Net: csak 80/443 (NPM Proxy).
Config: Minden konfiguráció .env fájlból vagy data.system_parameters táblából jön. Hardkódolás TILOS.
3. IDENTITÁS ÉS ONBOARDING (05, 07)
Szétválasztás:
USER: Technikai fiók (Email/Pass).
PERSON: Valós jogi személy (Okmányok, KYC). Nem törölhető.
Folyamat: Kétlépcsős (2-Step) Onboarding.
Lite: Csak User létrehozása (is_active=False).
KYC: Okmányok feltöltése -> Person létrehozása -> Wallet nyitása -> Aktiválás (Atomi tranzakció).
4. ATOMIZÁLT ASSET MODELL (18) [FRISSÍTVE 2026.02.10]
A járművek kezelése 4 elkülönített modulra bomlott (SRP elv):
Identity (Asset): VIN, Rendszám, Tulajdonos (AssetAssignment).
Catalog (AssetCatalog): Gyári statikus adatok. Robot Scout tölti.
Telemetry (AssetTelemetry): Változó állapot (KM, VQI, DBS).
Financials (AssetCost): Pénzügyi tranzakciók 9 kategóriában.
API Design: 3 külön végpont (/assets/{id}, /assets/{id}/costs, /assets/{id}/telemetry).
5. HIERARCHIKUS JOGOSULTSÁG (RBAC & SCOPE) (09, 19) [FRISSÍTVE 2026.02.10]
A rendszer egy Rang- és Hatókör-alapú mátrixot használ (RBAC_MASTER_CONFIG JSON).
Szintek (Rank):
SUPERADMIN (100): Globális (L0).
COUNTRY_ADMIN (80): Országos (L1).
REGION_ADMIN (60): Területi (L1/B).
MODERATOR (40): Adatvalidátor (L2).
SALES (20): Üzletkötő (L3).
USER (10): Végfelhasználó.
Védelem: Middleware szinten: Token Role >= Required Rank ÉS User Scope == Resource Scope.
Adattábla: User tábla új mezői: scope_level, scope_id, custom_permissions.
6. GAMIFICATION ÉS ECONOMY (10, 11) [FRISSÍTVE 2026.02.10]
XP (Tapasztalat): Végleges szintlépés (BaseXP×Level1.5). Nem csökken.
Social Points: Szezonális, resetelhető pontok.
Kredit: Valuta, Social pontokból váltható.
Service: GamificationService és PointsLedger (auditált naplózás).
Billing: Többvalutás rendszer (HUF/EUR tárolás).
7. ÜZEMELTETÉS ÉS ADATINTEGRITÁS (06, 12, 16, 17)
Soft Delete: Nincs DELETE parancs, csak is_deleted vagy is_active flag.
Audit: Kritikus műveletek (pl. Impersonation) előtt/után állapotmentés az audit_logs táblába.
Enum: Postgres Enum típusok mindig kisbetűsek (pl. role='user').
Migráció: Minden DB módosításhoz SQL script + Alembic migráció kötelező.
🚀 KÖVETKEZŐ LÉPÉSEK (ACTION PLAN - 2026.02.11)
A rendszer magja (Asset, RBAC, Gamification) stabil. A következő fejlesztési ciklus célja a biztonsági réteg és az automatizáció bekapcsolása.
🔴 PRIORITY 1: SMART AUTH TOKEN (Security)
Feladat: A Login (/auth/login) folyamat átírása.
Cél: A generált JWT Token tartalmazza a DB-ből frissen kinyert RBAC adatokat: role, rank, scope_level, scope_id.
Miért: Hogy a Middleware DB-lekérdezés nélkül tudjon dönteni a jogosultságról.
File: backend/app/core/security.py, backend/app/api/v1/endpoints/auth.py.
🟠 PRIORITY 2: IMPERSONATION ENGINE (Ops)
Feladat: POST /api/v1/admin/impersonate végpont.
Logika: SuperAdmin token cseréje egy cél-felhasználó tokenjére (időkorlátos).
Biztonság: Szigorú naplózás az audit_logs táblába (reason kötelező).
🟡 PRIORITY 3: ROBOT SCOUT (Automation)
Feladat: Háttérfolyamat (Worker) indítása create_asset után.
Logika: VIN alapján külső API / Mock adatbázis lekérdezése -> AssetCatalog.factory_data feltöltése.
# 📘 SERVICE FINDER - MASTER ARCHITECT SYSTEM INSTRUCTIONS # 📘 SERVICE FINDER - MASTER ARCHITECT SYSTEM INSTRUCTIONS
ROLE: Senior Technical Product Manager & System Architect PROJECT: Service Finder - Traffic Ecosystem SuperApp 2.0 CONTEXT: Monolit-moduláris refaktorálás (FastAPI backend, Vue3 frontend, PostgreSQL 15). SSoT: Grand Master Book (v1.4). ROLE: Senior Technical Product Manager & System Architect PROJECT: Service Finder - Traffic Ecosystem SuperApp 2.0 CONTEXT: Monolit-moduláris refaktorálás (FastAPI backend, Vue3 frontend, PostgreSQL 15). SSoT: Grand Master Book (v1.4).

View File

@@ -1 +0,0 @@
Generic single-database configuration.

View File

@@ -1,75 +0,0 @@
import asyncio
from logging.config import fileConfig
import os
import sys
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# --- ÚTVONAL JAVÍTÁS ---
# Az aktuális fájl (env.py) helyéből kiindulva meghatározzuk a könyvtárakat
current_dir = os.path.dirname(os.path.realpath(__file__))
project_root = os.path.realpath(os.path.join(current_dir, '..'))
backend_dir = os.path.join(project_root, 'backend')
# Mindkét útvonalat betesszük a keresőbe, hogy a 'backend.app' és a sima 'app' is működjön
sys.path.insert(0, project_root)
sys.path.insert(0, backend_dir)
# Most már az Alembic megtalálja a konfigurációt és a modelleket
try:
from app.core.config import settings
from app.db.base import Base
from app.models import * # Fontos, hogy minden modell be legyen importálva!
except ImportError as e:
print(f"Hiba az importálásnál: {e}")
print(f"Próbált útvonalak: {sys.path}")
raise
config = context.config
# Dinamikus adatbázis URL a .env alapján (App User jelszavával)
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def do_run_migrations(connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
include_schemas=True, # Adatbázis sémák (pl. 'data') támogatása
version_table_schema='public' # Alembic tábla a public-ban marad
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""Aszinkron kapcsolat felépítése és migráció futtatása"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
include_schemas=True
)
with context.begin_transaction():
context.run_migrations()
else:
asyncio.run(run_migrations_online())

Some files were not shown because too many files have changed in this diff Show More