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
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
@@ -6,24 +6,27 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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
logger = logging.getLogger(__name__)
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
db: AsyncSession = Depends(get_db),
token: str = Depends(reusable_oauth2),
) -> User:
async def get_current_token_payload(
token: str = Depends(reusable_oauth2)
) -> Dict[str, Any]:
"""
Dependency, amely visszaadja az aktuálisan bejelentkezett felhasználót.
Támogatja a 'dev_bypass_active' tokent a fejlesztői teszteléshez.
Kinyeri a token payload-ot DB hívás nélkül.
Ez teszi lehetővé a gyors jogosultság-ellenőrzést.
"""
# FEJLESZTŐI BYPASS
if token == "dev_bypass_active":
result = await db.execute(select(User).where(User.id == 1))
return result.scalar_one()
return {
"sub": "1",
"role": "superadmin",
"rank": 100,
"scope_level": "global",
"scope_id": "all"
}
payload = decode_token(token)
if not payload:
@@ -31,27 +34,38 @@ async def get_current_user(
status_code=status.HTTP_401_UNAUTHORIZED,
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:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token azonosítási hiba."
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token azonosítási hiba.")
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
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."
)
if not user or user.is_deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="A felhasználó nem található.")
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 sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
import os
import logging
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.db.session import get_db
from app.api.deps import get_current_user
from app.schemas.asset import AssetCreate, AssetResponse
from app.models.asset import Asset, AssetCatalog, AssetAssignment, AssetEvent
from app.models.asset import Asset, AssetCost, AssetTelemetry
from app.models.identity import User
from app.models.organization import Organization, OrganizationMember, OrgType
from app.core.config import settings
# VIN Validator - Standard 17 karakter, tiltott karakterek (I, O, Q) szűrése
class VINValidator:
@staticmethod
def validate(vin: str) -> bool:
vin = vin.upper()
if len(vin) != 17:
return False
if any(c in vin for c in "IOQ"):
return False
return True
from app.services.cost_service import cost_service
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
async def create_asset(
asset_in: AssetCreate,
target_org_id: int = None,
# --- 1. MODUL: IDENTITÁS (Alapadatok) ---
@router.get("/{asset_id}", response_model=Dict[str, Any])
async def get_asset_identity(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# 1. VIN Validáció
if not VINValidator.validate(asset_in.vin):
raise HTTPException(status_code=400, detail="Érvénytelen alvázszám (VIN) formátum!")
"""Csak a jármű alapadatai és katalógus információi."""
stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.catalog))
asset = (await db.execute(stmt)).scalar_one_or_none()
# 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
if not asset:
raise HTTPException(status_code=404, detail="Jármű nem található")
# 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()
return {
"id": asset.id,
"vin": asset.vin,
"license_plate": asset.license_plate,
"name": asset.name,
"catalog": {
"make": asset.catalog.make,
"model": asset.catalog.model,
"type": asset.catalog.vehicle_class,
"factory_data": getattr(asset.catalog, 'factory_data', {})
}
}
if not catalog_item:
catalog_item = AssetCatalog(
make=asset_in.make,
model=asset_in.model,
vehicle_class=asset_in.vehicle_class,
fuel_type=asset_in.fuel_type
)
db.add(catalog_item)
await db.flush()
# --- 2. MODUL: PÉNZÜGY (Költségek) ---
@router.get("/{asset_id}/costs", response_model=Dict[str, Any])
async def get_asset_costs(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Pénzügyi modul: Helyi és EUR alapú összesítő, tételes lista."""
stmt = select(AssetCost).where(AssetCost.asset_id == asset_id)
costs = (await db.execute(stmt)).scalars().all()
# 4. Asset létrehozása vagy betöltése (Shadow Identity)
stmt_exist = select(Asset).where(Asset.vin == asset_in.vin.upper())
new_asset = (await db.execute(stmt_exist)).scalar_one_or_none()
summary_local = {}
summary_eur = {}
history = []
if not new_asset:
new_asset = Asset(
vin=asset_in.vin.upper(),
license_plate=asset_in.license_plate,
name=asset_in.name or f"{asset_in.make} {asset_in.model}",
year_of_manufacture=asset_in.year_of_manufacture,
fuel_type=asset_in.fuel_type, # JAVÍTVA: Most már átadjuk
vehicle_class=asset_in.vehicle_class, # JAVÍTVA: Most már átadjuk
mileage_unit=asset_in.reading_unit, # JAVÍTVA: Most már átadjuk
catalog_id=catalog_item.id,
quality_index=1.00,
system_mileage=0
)
db.add(new_asset)
await db.flush()
for c in costs:
cat = c.cost_type or "OTHER"
amt_local = float(c.amount_local)
amt_eur = float(c.amount_eur) if c.amount_eur else 0.0
# 5. Assignment
new_assignment = AssetAssignment(
asset_id=new_asset.id,
organization_id=final_org_id,
status="active"
)
db.add(new_assignment)
summary_local[cat] = summary_local.get(cat, 0) + amt_local
summary_eur[cat] = summary_eur.get(cat, 0) + amt_eur
# 6. Kezdő KM esemény
if asset_in.current_reading:
db.add(AssetEvent(
asset_id=new_asset.id,
event_type="initial_reading",
recorded_mileage=asset_in.current_reading,
description="Kezdeti óraállás rögzítése",
data={"source": "user_registration"}
))
history.append({
"id": c.id,
"category": cat,
"amount_local": amt_local,
"currency_local": c.currency_local,
"amount_eur": amt_eur,
"exchange_rate": float(c.exchange_rate_used) if c.exchange_rate_used else 1.0,
"date": c.date
})
return {
"total_gross_local": sum(summary_local.values()),
"total_gross_eur": sum(summary_eur.values()),
"summary_local": summary_local,
"summary_eur": summary_eur,
"history": history
}
@router.post("/{asset_id}/costs", response_model=AssetCostResponse, status_code=status.HTTP_201_CREATED)
async def create_asset_cost(
asset_id: uuid.UUID,
cost_in: AssetCostCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Új költség rögzítése.
Automatikus: EUR konverzió, Telemetria frissítés, XP jóváírás.
"""
# Validáció: az asset_id-nak egyeznie kell a path-szal
if cost_in.asset_id != asset_id:
raise HTTPException(status_code=400, detail="Asset ID mismatch")
try:
await db.commit()
await db.refresh(new_asset)
# 7. NAS mappa struktúra
nas_base = getattr(settings, "NAS_STORAGE_PATH", "/opt/docker/dev/service_finder/nas/assets")
asset_path = os.path.join(nas_base, str(new_asset.id))
os.makedirs(os.path.join(asset_path, "docs"), exist_ok=True)
os.makedirs(os.path.join(asset_path, "photos"), exist_ok=True)
return new_asset
new_cost = await cost_service.record_cost(
db=db,
cost_in=cost_in,
user_id=current_user.id
)
return new_cost
except Exception as e:
await db.rollback()
logger.error(f"Asset Creation Error: {str(e)}")
raise HTTPException(status_code=500, detail="Hiba a mentés során.")
raise HTTPException(status_code=500, detail=str(e))
# --- 3. MODUL: TELEMETRIA (Állapot) ---
@router.get("/{asset_id}/telemetry", response_model=Dict[str, Any])
async def get_asset_telemetry(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Műszaki állapot: KM óra, VQI (Quality) és DBS (Driving) pontszámok."""
stmt = select(AssetTelemetry).where(AssetTelemetry.asset_id == asset_id)
tel = (await db.execute(stmt)).scalar_one_or_none()
if not tel:
return {"current_mileage": 0, "vqi_score": 100.0, "dbs_score": 100.0}
return {
"current_mileage": tel.current_mileage,
"vqi_score": float(tel.vqi_score),
"dbs_score": float(tel.dbs_score),
"last_update": tel.updated_at if hasattr(tel, 'updated_at') else None
}

View File

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

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

View File

@@ -1,6 +1,5 @@
import os
import json
from typing import Any, Optional, List
from typing import Any, Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,41 +9,38 @@ class Settings(BaseSettings):
PROJECT_NAME: str = "Traffic Ecosystem SuperApp"
VERSION: str = "1.0.0"
API_V1_STR: str = "/api/v1"
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
DEBUG: bool = False
# --- Security / JWT ---
# Szigorúan .env-ből!
SECRET_KEY: str = os.getenv("SECRET_KEY", "NOT_SET_DANGER")
SECRET_KEY: str = "NOT_SET_DANGER"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 nap
# --- Database & Cache ---
DATABASE_URL: str = os.getenv("DATABASE_URL")
REDIS_URL: str = os.getenv("REDIS_URL", "redis://service_finder_redis:6379/0")
# --- Initial Admin (ÚJ SZEKCIÓ) ---
# Ezeket a .env-ből fogja venni
INITIAL_ADMIN_EMAIL: str = "admin@servicefinder.hu"
INITIAL_ADMIN_PASSWORD: str = "Admin123!"
# --- Email (Auto Provider) ---
EMAIL_PROVIDER: str = os.getenv("EMAIL_PROVIDER", "auto")
EMAILS_FROM_EMAIL: str = os.getenv("EMAILS_FROM_EMAIL", "info@profibot.hu")
# --- Database & Cache ---
DATABASE_URL: str
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"
# SMTP & SendGrid (Szigorúan .env-ből)
SENDGRID_API_KEY: Optional[str] = os.getenv("SENDGRID_API_KEY")
SMTP_HOST: Optional[str] = os.getenv("SMTP_HOST")
SMTP_PORT: int = int(os.getenv("SMTP_PORT", 587))
SMTP_USER: Optional[str] = os.getenv("SMTP_USER")
SMTP_PASSWORD: Optional[str] = os.getenv("SMTP_PASSWORD")
SENDGRID_API_KEY: Optional[str] = None
SMTP_HOST: Optional[str] = None
SMTP_PORT: int = 587
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
# --- External URLs ---
# .env-ben legyen átírva a .10-es IP-re!
FRONTEND_BASE_URL: str = os.getenv("FRONTEND_BASE_URL", "http://localhost:3000")
FRONTEND_BASE_URL: str = "http://localhost:3000"
# --- Dinamikus Admin Motor ---
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:
query = text("SELECT value_json FROM data.system_settings WHERE key_name = :key")
result = await db.execute(query, {"key": key_name})
@@ -63,5 +59,4 @@ class Settings(BaseSettings):
extra="ignore"
)
settings = Settings()

View File

@@ -1,3 +1,4 @@
# /opt/docker/dev/service_finder/backend/app/core/i18n.py
import json
import os
@@ -9,21 +10,44 @@ class LocaleManager:
self._load()
data = self._locales.get(lang, self._locales.get("hu", {}))
# Biztonságos bejárás a pontokkal elválasztott kulcsokhoz
for k in key.split("."):
if isinstance(data, dict):
data = data.get(k, {})
else:
return key # Ha elakadunk, adjuk vissza magát a kulcsot
if isinstance(data, str):
return data.format(**kwargs)
return key
def _load(self):
path = "backend/app/locales" # Konténeren belül: "/app/app/locales"
if not os.path.exists(path): path = "app/locales"
# A konténeren belül ez a biztos útvonal
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):
if file.endswith(".json"):
lang = file.split(".")[0]
try:
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()
# 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 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:
"""Összehasonlítja a sima szöveges jelszót a hash-elt változattal."""
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")
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()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
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)
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
# 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.identity import User, Person, VerificationToken, Wallet # noqa
from app.models.organization import Organization, OrganizationMember # noqa
from app.models.asset import Asset, AssetCatalog, AssetCost, AssetEvent # noqa
from app.models.gamification import UserStats, PointsLedger # noqa
from app.models.asset import ( # 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": {
"registration_subject": "Regisztráció - Service Finder",
"password_reset_subject": "Jelszó visszaállítás - Service Finder",
"reg_subject": "Regisztráció - Service Finder",
"pwd_reset_subject": "Jelszó visszaállítás - Service Finder",
"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_button": "Fiók Aktiválása",
@@ -9,6 +9,23 @@
"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_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 .identity import User, Person, Wallet, UserRole, VerificationToken
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 .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
UserVehicle = Asset
VehicleCatalog = AssetCatalog
ServiceRecord = AssetEvent
__all__ = [
"Base",
"User",
"Person",
"Wallet",
"UserRole",
"VerificationToken",
"Organization",
"OrganizationMember",
"Asset",
"AssetCatalog",
"AssetCost",
"AssetEvent",
"Address",
"GeoPostalCode",
"GeoStreet",
"GeoStreetType",
"UserStats",
"PointsLedger",
"Vehicle",
"UserVehicle",
"VehicleCatalog",
"ServiceRecord"
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken",
"Organization", "OrganizationMember", "Asset", "AssetCatalog", "AssetCost",
"AssetEvent", "AssetFinancials", "AssetTelemetry", "AssetReview", "ExchangeRate",
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "PointRule",
"LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger",
"SystemParameter", "Document", "SubscriptionTier", "OrganizationSubscription",
"CreditTransaction", "ServiceSpecialty", "AuditLog", "VehicleOwnership",
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord"
]

Binary file not shown.

View File

@@ -1,133 +1,128 @@
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.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.sql import func
from app.db.base_class import Base
class AssetCatalog(Base):
"""Központi jármű katalógus (Admin/Bot által tölthető)"""
__tablename__ = "vehicle_catalog"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
make = Column(String, index=True, nullable=False)
model = Column(String, index=True, nullable=False)
generation = Column(String)
year_from = Column(Integer)
year_to = Column(Integer)
vehicle_class = Column(String) # land, sea, air
vehicle_class = Column(String)
fuel_type = Column(String)
engine_code = Column(String)
factory_data = Column(JSON, server_default=text("'{}'::jsonb"))
assets = relationship("Asset", back_populates="catalog")
class Asset(Base):
"""A Jármű Identitás (Digital Twin törzsadatok)"""
__tablename__ = "assets"
__table_args__ = {"schema": "data"}
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
vin = Column(String(17), unique=True, index=True, nullable=False)
license_plate = Column(String(20), index=True)
name = Column(String)
year_of_manufacture = Column(Integer)
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)
status = Column(String(20), default="active")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
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")
events = relationship("AssetEvent", 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):
"""Birtoklás követése (Kié a jármű és mettől meddig)"""
__tablename__ = "asset_assignments"
__table_args__ = {"schema": "data"}
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)
organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False)
assigned_at = Column(DateTime(timezone=True), server_default=func.now())
released_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String(30), default="active")
notes = Column(String)
asset = relationship("Asset", back_populates="assignments")
organization = relationship("Organization", back_populates="assets")
class AssetEvent(Base):
"""Élettörténeti események (Szerviz, km-óra állások, balesetek)"""
__tablename__ = "asset_events"
__table_args__ = {"schema": "data"}
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)
event_type = Column(String(50), nullable=False)
event_date = Column(DateTime(timezone=True), server_default=func.now())
recorded_mileage = Column(Integer)
description = Column(String)
data = Column(JSON, server_default=text("'{}'::jsonb"))
asset = relationship("Asset", back_populates="events")
class AssetCost(Base):
"""
Költségkezelő modell: Bruttó/Nettó/ÁFA és deviza támogatás.
"""
__tablename__ = "asset_costs"
__table_args__ = {"schema": "data"}
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)
organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False)
cost_type = Column(String(50), nullable=False) # fuel, service, tax, insurance, toll
# Pénzügyi adatok
amount = Column(Numeric(18, 2), nullable=False) # Bruttó összeg
net_amount = Column(Numeric(18, 2)) # Nettó összeg
vat_amount = Column(Numeric(18, 2)) # ÁFA érték
vat_rate = Column(Numeric(5, 2)) # ÁFA kulcs (pl. 27.00)
# 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
driver_id = Column(Integer, ForeignKey("data.users.id"), nullable=True)
cost_type = Column(String(50), nullable=False)
amount_local = Column(Numeric(18, 2), nullable=False)
currency_local = Column(String(3), nullable=False)
amount_eur = Column(Numeric(18, 2), nullable=True)
net_amount_local = Column(Numeric(18, 2))
vat_rate = Column(Numeric(5, 2))
exchange_rate_used = Column(Numeric(18, 6))
date = Column(DateTime(timezone=True), server_default=func.now())
description = Column(String)
invoice_id = Column(String)
mileage_at_cost = Column(Integer)
data = Column(JSON, server_default=text("'{}'::jsonb"))
asset = relationship("Asset", back_populates="costs")
class ExchangeRate(Base):
"""Napi árfolyamok tárolása (ECB/MNB adatok alapján)"""
__tablename__ = "exchange_rates"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, primary_key=True)
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_date = Column(DateTime(timezone=False), index=True)
provider = Column(String(50), default="ECB")
updated_at = Column(DateTime(timezone=True), server_default=func.now())
rate_date = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=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.orm import relationship
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):
__tablename__ = "subscription_tiers"

View File

@@ -2,7 +2,8 @@ from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
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):
__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.orm import relationship
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):
__tablename__ = "vehicle_ownerships"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
# Kapcsolatok
vehicle_id = Column(Integer, ForeignKey("data.user_vehicles.id"), nullable=False)
vehicle_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False)
# Időszak
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)
start_date = Column(Date, nullable=False, default=func.current_date())
end_date = Column(Date, nullable=True)
notes = Column(Text, nullable=True)
# SQLAlchemy kapcsolatok (visszahivatkozások a fő modellekben kellenek majd)
vehicle = relationship("UserVehicle", back_populates="ownership_history")
user = relationship("User", back_populates="owned_vehicles")
vehicle = relationship("Asset", back_populates="ownership_history")
user = relationship("User", back_populates="ownership_history")
# --- 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):
__tablename__ = "audit_logs"
__table_args__ = {"schema": "data"}
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)
# MIT? (Milyen objektumot érintett?)
target_type = Column(String, index=True) # pl. "vehicle", "cost", "user_profile"
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ú!
target_type = Column(String, index=True)
target_id = Column(String, index=True)
action = Column(String, nullable=False)
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())
# Kapcsolat (Opcionális, csak ha le akarjuk kérdezni a user adatait a logból)
user = relationship("User")

View File

@@ -12,6 +12,7 @@ class UserRole(str, enum.Enum):
service = "service"
fleet_manager = "fleet_manager"
driver = "driver"
superadmin = "superadmin" # Hozzáadva a biztonság kedvéért
class Person(Base):
__tablename__ = "persons"
@@ -51,34 +52,37 @@ class User(Base):
region_code = Column(String, default="HU")
is_deleted = Column(Boolean, default=False)
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")
wallet = relationship("Wallet", back_populates="user", uselist=False)
# RBAC & SCOPE mezők (Visszaállítva a DB sémához)
scope_level = Column(String(30), server_default="individual")
scope_id = Column(String(50))
custom_permissions = Column(JSON, server_default=text("'{}'::jsonb"))
# Itt a trükk: csak a string hivatkozás marad, így nincs import hiba,
# 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())
# 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):
"""Kétoszlopos pénztárca: Coin (Szerviz) és Kredit (Prémium)."""
__tablename__ = "wallets"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("data.users.id"), unique=True)
coin_balance = Column(Numeric(18, 2), default=0.00)
credit_balance = Column(Numeric(18, 2), default=0.00)
currency = Column(String(3), default="HUF")
user = relationship("User", back_populates="wallet")
class VerificationToken(Base):
__tablename__ = "verification_tokens"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
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)

View File

@@ -20,16 +20,16 @@ class Organization(Base):
__table_args__ = {"schema": "data"}
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)
# --- NÉVKEZELÉS ---
full_name = Column(String, nullable=False) # Teljes hivatalos név
name = Column(String, nullable=False) # Rövidített cégnév
display_name = Column(String(50)) # Alkalmazáson belüli rövidítés
full_name = Column(String, nullable=False)
name = Column(String, nullable=False)
display_name = Column(String(50))
default_currency = Column(String(3), default="HUF")
country_code = Column(String(2), default="HU")
language = Column(String(5), default="hu")
# --- ATOMIZÁLT CÍMKEZELÉS (Kompatibilitási réteg) ---
address_zip = Column(String(10))
address_city = Column(String(100))
address_street_name = Column(String(150))
@@ -39,9 +39,7 @@ class Organization(Base):
address_stairwell = Column(String(20))
address_floor = 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)
reg_number = Column(String(50))
@@ -65,7 +63,7 @@ class Organization(Base):
created_at = Column(DateTime(timezone=True), server_default=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")
members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan")
owner = relationship("User", back_populates="owned_organizations")
@@ -76,13 +74,9 @@ class OrganizationMember(Base):
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(Integer, ForeignKey("data.organizations.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"))
organization = relationship("Organization", back_populates="members")
user = relationship("app.models.identity.User") # Visszamutató kapcsolat a felhasználóra
# Kompatibilitási réteg
Organization.vehicles = Organization.assets
user = relationship("User") # Egyszerűsített string hivatkozás

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 typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, Dict, Any, List
from uuid import UUID
from datetime import datetime
class AssetCreate(BaseModel):
# Alapadatok
make: str = Field(..., example="Ford")
model: str = Field(..., example="Mondeo")
vin: str = Field(..., min_length=17, max_length=17, description="Alvázszám")
license_plate: Optional[str] = Field(None, max_length=20, example="RRR-555")
# --- KATALÓGUS SÉMÁK (Gyári adatok) ---
class AssetCatalogBase(BaseModel):
make: str
model: str
generation: Optional[str] = None
year_from: Optional[int] = None
year_to: Optional[int] = None
vehicle_class: Optional[str] = None
fuel_type: Optional[str] = None
engine_code: Optional[str] = None
# Nemzetközi és Admin szempontok
vehicle_class: str = Field("land", description="land, sea, air - Admin által bővíthető")
fuel_type: str = Field(..., example="Diesel", description="Admin által definiált üzemanyag típusok")
class AssetCatalogResponse(AssetCatalogBase):
id: int
factory_data: Optional[Dict[str, Any]] = None # A robot által gyűjtött adatok
# Technikai adatok
engine_description: Optional[str] = Field(None, example="2.0 TDCI")
year_of_manufacture: int = Field(..., ge=1900, le=2100)
model_config = ConfigDict(from_attributes=True)
# 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):
id: UUID
catalog_id: Optional[int]
vin: str
license_plate: Optional[str] = None # JAVÍTVA: Lehet None a válaszban
# --- 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
fuel_type: str
vehicle_class: str
is_verified: bool
year_of_manufacture: int
system_mileage: int
quality_index: float
created_at: datetime
year_of_manufacture: Optional[int] = None
class Config:
from_attributes = True
class AssetCreate(AssetBase):
# 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 typing import Optional, Dict
from typing import Optional, Dict, Any
from datetime import date
# --- STEP 1: LITE REGISTRATION ---
@@ -9,6 +9,8 @@ class UserLiteRegister(BaseModel):
first_name: str
last_name: str
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):
email: EmailStr
@@ -30,15 +32,15 @@ class UserKYCComplete(BaseModel):
birth_date: date
mothers_last_name: str
mothers_first_name: str
# Hibrid Címmezők
address_zip: str
address_city: str
address_street_name: str
address_street_type: str
address_house_number: str
address_hrsz: Optional[str] = None # Helyrajzi szám
address_hrsz: Optional[str] = None
identity_docs: Dict[str, DocumentDetail]
ice_contact: ICEContact
preferred_currency: Optional[str] = Field("HUF", max_length=3)
# --- COMMON & SECURITY ---
class PasswordResetRequest(BaseModel):
@@ -54,3 +56,12 @@ class Token(BaseModel):
access_token: str
token_type: str
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
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
# 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
from datetime import datetime
import logging
import uuid
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.legal import LegalDocument
from app.models.email_template import EmailTemplate, EmailType
from app.models.email_provider import EmailProviderConfig
from app.models import (
User, Person, UserRole, SystemParameter,
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 with SessionLocal() as db:
# 1. Jogi dokumentumok (HU)
legal_docs = [
LegalDocument(
title="Általános Szerződési Feltételek",
content="Ide jön az ÁSZF szövege... Kérjük görgessen az aljáig.",
version="v1.0",
region_code="HU",
language="hu"
),
LegalDocument(
title="Adatkezelési Tájékoztató (GDPR)",
content="Ide jön a GDPR szövege... Kérjük görgessen az aljáig.",
version="v1.0",
region_code="HU",
language="hu"
logger.info("🚀 Alapadatok feltöltése biztonságos módban...")
admin_email = settings.INITIAL_ADMIN_EMAIL
admin_password = settings.INITIAL_ADMIN_PASSWORD
if not admin_email or not admin_password:
logger.error("❌ HIBA: INITIAL_ADMIN_EMAIL vagy PASSWORD nincs beállítva!")
return
stmt = select(User).where(User.email == admin_email)
admin_exists = (await db.execute(stmt)).scalar_one_or_none()
if not admin_exists:
new_person = Person(
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:
stmt_rule = select(PointRule).where(PointRule.action_key == key)
if not (await db.execute(stmt_rule)).scalar_one_or_none():
db.add(PointRule(action_key=key, points=pts, description=desc))
# 2. Email Sablon (Regisztráció)
reg_template = EmailTemplate(
type=EmailType.REGISTRATION,
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. Gamification Szintek ---
stmt_level = select(LevelConfig)
if not (await db.execute(stmt_level)).first():
db.add_all([
LevelConfig(level_number=1, min_points=0, rank_name="Kezdő Sofőr"),
LevelConfig(level_number=2, min_points=500, rank_name="Tapasztalt Vezető"),
LevelConfig(level_number=3, min_points=2000, rank_name="Flotta Mester")
])
# 3. Email Szolgáltató (SendGrid)
sendgrid_provider = EmailProviderConfig(
name="SendGrid_Primary",
provider_type="SENDGRID",
priority=1,
settings={"api_key": "YOUR_SENDGRID_KEY_HERE"}, # Ezt majd az adminon írjuk át
max_fail_threshold=3
)
# --- 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()
print("🌱 Alapadatok sikeresen feltöltve!")
logger.info(" A rendszer alapadatai és a Gamification motor készen áll!")
if __name__ == "__main__":
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 app.models.identity import User, Person, UserRole, VerificationToken, Wallet
from app.models.gamification import UserStats # <--- Innen importáljuk mostantól!
from app.models.gamification import UserStats
from app.models.organization import Organization, OrganizationMember, OrgType
from app.schemas.auth import UserLiteRegister, UserKYCComplete
from app.core.security import get_password_hash, verify_password
@@ -26,7 +26,7 @@ class AuthService:
async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
"""
Step 1: Lite Regisztráció (Master Book 1.1)
Új User és ideiglenes Person rekord létrehozása.
Új User és ideiglenes Person rekord létrehozása nyelvi és időzóna adatokkal.
"""
try:
# Ideiglenes Person rekord a KYC-ig
@@ -45,7 +45,10 @@ class AuthService:
role=UserRole.user,
is_active=False,
is_deleted=False,
region_code=user_in.region_code
region_code=user_in.region_code,
# --- NYELVI ÉS ADMIN BEÁLLÍTÁSOK MENTÉSE ---
preferred_language=user_in.lang,
timezone=user_in.timezone
)
db.add(new_user)
await db.flush()
@@ -60,12 +63,14 @@ class AuthService:
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_hours))
))
# Email küldés (Master Book 3.2: Nincs manuális subject)
# --- EMAIL KÜLDÉSE A VÁLASZTOTT NYELVEN ---
# Master Book 3.2: Nincs manuális subject, a nyelvi kulcs alapján töltődik be
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
await email_manager.send_email(
recipient=user_in.email,
template_key="registration",
variables={"first_name": user_in.first_name, "link": verification_link}
template_key="reg", # hu.json: email.reg_subject, reg_greeting stb.
variables={"first_name": user_in.first_name, "link": verification_link},
lang=user_in.lang # Dinamikus nyelvválasztás
)
await db.commit()
@@ -81,6 +86,7 @@ class AuthService:
"""
1.3. Fázis: Atomi Tranzakció & Shadow Identity
Felismeri a visszatérő Person-t, de új User-ként, izolált flottával indít.
Frissíti a nyelvi és pénzügyi beállításokat.
"""
try:
# 1. Aktuális technikai User lekérése
@@ -89,8 +95,11 @@ class AuthService:
user = res.scalar_one_or_none()
if not user: return None
# 2. Shadow Identity Ellenőrzése (Anyja neve + Születési hely + Idő alapján)
# Globális keresés, régiótól függetlenül
# --- PÉNZNEM PREFERENCIA FRISSÍTÉSE ---
if hasattr(kyc_in, 'preferred_currency') and kyc_in.preferred_currency:
user.preferred_currency = kyc_in.preferred_currency
# 2. Shadow Identity Ellenőrzése
identity_stmt = select(Person).where(and_(
Person.mothers_last_name == kyc_in.mothers_last_name,
Person.mothers_first_name == kyc_in.mothers_first_name,
@@ -100,7 +109,6 @@ class AuthService:
existing_person = (await db.execute(identity_stmt)).scalar_one_or_none()
if existing_person:
# Visszatérő identitás: A User-t a régi Person-hoz kötjük
user.person_id = existing_person.id
active_person = existing_person
logger.info(f"Shadow Identity linked: User {user_id} -> Person {existing_person.id}")
@@ -118,7 +126,7 @@ class AuthService:
parcel_id=kyc_in.address_hrsz
)
# 4. Person adatok frissítése (mindig a legfrissebbet tároljuk)
# 4. Person adatok frissítése
active_person.mothers_last_name = kyc_in.mothers_last_name
active_person.mothers_first_name = kyc_in.mothers_first_name
active_person.birth_place = kyc_in.birth_place
@@ -129,7 +137,7 @@ class AuthService:
active_person.ice_contact = jsonable_encoder(kyc_in.ice_contact)
active_person.is_active = True
# 5. Új, izolált INDIVIDUAL szervezet (4.2.3)
# 5. Új, izolált INDIVIDUAL szervezet (4.2.3) i18n beállításokkal
new_org = Organization(
full_name=f"{active_person.last_name} {active_person.first_name} Egyéni Flotta",
name=f"{active_person.last_name} Flotta",
@@ -137,7 +145,11 @@ class AuthService:
owner_id=user.id,
is_transferable=False,
is_active=True,
status="verified"
status="verified",
# Megörökölt adminisztrációs adatok
language=user.preferred_language,
default_currency=user.preferred_currency,
country_code=user.region_code
)
db.add(new_org)
await db.flush()
@@ -150,8 +162,13 @@ class AuthService:
permissions={"can_add_asset": True, "can_view_costs": True, "is_admin": True}
))
# 7. Wallet & Stats (Friss kezdés 0 ponttal)
db.add(Wallet(user_id=user.id, coin_balance=0, credit_balance=0))
# 7. Wallet & Stats
db.add(Wallet(
user_id=user.id,
coin_balance=0,
credit_balance=0,
currency=user.preferred_currency
))
db.add(UserStats(user_id=user.id, total_xp=0, current_level=1))
# 8. Aktiválás
@@ -197,7 +214,6 @@ class AuthService:
@staticmethod
async def initiate_password_reset(db: AsyncSession, email: str):
# Csak aktív (nem törölt) felhasználónak engedünk jelszót resetelni
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
user = (await db.execute(stmt)).scalar_one_or_none()
@@ -211,11 +227,13 @@ class AuthService:
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_hours))
))
# --- EMAIL KÜLDÉSE A FELHASZNÁLÓ SAJÁT NYELVÉN ---
reset_link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
await email_manager.send_email(
recipient=email,
template_key="password_reset",
variables={"link": reset_link}
template_key="pwd_reset", # hu.json: email.pwd_reset_subject stb.
variables={"link": reset_link},
lang=user.preferred_language # Adatbázisból kinyert nyelv
)
await db.commit()
return "success"

View File

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

View File

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

View File

@@ -1,26 +1,47 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text
from sqlalchemy import select
from app.models.gamification import UserStats, PointsLedger
from app.models.identity import User
import math
class GamificationService:
@staticmethod
async def award_points(db: AsyncSession, user_id: int, points: int, reason: str):
"""Pontok jóváírása (SQL szinkronizált points mezővel)."""
new_entry = PointsLedger(
user_id=user_id,
points=points, # Javítva: points_change helyett points
reason=reason
)
db.add(new_entry)
result = await db.execute(select(UserStats).where(UserStats.user_id == user_id))
stats = result.scalar_one_or_none()
async def process_activity(db: AsyncSession, user_id: int, xp_amount: int, social_amount: int, reason: str):
"""
XP növelés, Szintlépés csekk és Automata Kredit váltás.
"""
# 1. User statisztika lekérése
stmt = select(UserStats).where(UserStats.user_id == user_id)
stats = (await db.execute(stmt)).scalar_one_or_none()
if not stats:
stats = UserStats(user_id=user_id, total_points=0, current_level=1)
stats = UserStats(user_id=user_id, total_xp=0, social_points=0, current_level=1, credits=0)
db.add(stats)
stats.total_points += points
await db.flush()
return stats.total_points
# 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
# Külön log a váltásról
db.add(PointsLedger(user_id=user_id, reason=f"Auto-conversion: {new_credits} Credits", credits_change=new_credits))
await db.commit()
return stats

View File

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

View File

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

View File

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

Binary file not shown.

View File

@@ -8,28 +8,18 @@ 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')
sys.path.insert(0, "/app")
# 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!
# Minden modellt importálunk a szinkronhoz
import app.models
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:
@@ -37,37 +27,40 @@ if config.config_file_name is not None:
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):
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
include_schemas=True,
include_object=include_object,
version_table_schema='public'
)
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,
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
include_schemas=True
include_schemas=True,
include_object=include_object
)
with context.begin_transaction():
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:
# 1. MIGRÁCIÓ (Adatbázis szerkezet frissítése)
migrate:
@@ -7,9 +9,7 @@ services:
container_name: service_finder_migrate
env_file: .env
volumes:
- ./backend:/app
- ./alembic.ini:/app/alembic.ini
- ./migrations:/app/migrations
- ./backend:/app # Ez tartalmazza az alembic.ini-t és a migrations mappát is!
environment:
PYTHONPATH: /app
DATABASE_URL: ${MIGRATION_DATABASE_URL}
@@ -28,10 +28,8 @@ services:
env_file: .env
volumes:
- ./backend:/app
- ./alembic.ini:/app/alembic.ini
- ./migrations:/app/migrations
- /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="*"
ports:
- "8000:8000"
@@ -72,7 +70,7 @@ services:
- default
restart: unless-stopped
# 4. REDIS (Lokális cache, NAS perzisztencia)
# 4. REDIS (Lokális cache)
redis:
image: redis:alpine
container_name: service_finder_redis

View File

@@ -88,3 +88,17 @@ Minden hónap végén az első 3 helyezett extra Kreditet vagy "Voucher"-t kap,
- **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.
- **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

@@ -210,3 +210,43 @@ A fejlesztések rendben tartásához javaslom a **`17_DEVELOPER_NOTES_AND_PITFAL
### 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.
## [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

@@ -187,3 +187,18 @@ A járműadatok kezelése hibrid módon történik.
- Minden Asset-hez rögzíthető költség (`asset_costs`).
- Kötelező adatok: Kategória, Összeg, Dátum.
- 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

@@ -87,3 +87,19 @@ A rendszer minden fontos paramétere a `data.system_settings` táblában tárolt
* **Service Hunt:** Távolságok, XP/Kredit szorzók.
* **Fraud Protection:** Strike limitek, kitiltási idők.
* **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
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