feat: Asset Catalog system, PostGIS integration and RobotScout V1

This commit is contained in:
2026-02-11 22:47:38 +00:00
parent a63e6c8fac
commit 09a0430384
53 changed files with 2756 additions and 426 deletions

View File

@@ -1,4 +1,4 @@
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, Union
import logging
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
@@ -6,26 +6,37 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.core.security import decode_token, RANK_MAP
from app.models.identity import User
from app.core.security import decode_token, DEFAULT_RANK_MAP
from app.models.identity import User, UserRole
from app.core.config import settings
logger = logging.getLogger(__name__)
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
# Az OAuth2 folyamat a bejelentkezési végponton keresztül
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
async def get_current_token_payload(
token: str = Depends(reusable_oauth2)
) -> Dict[str, Any]:
if token == "dev_bypass_active":
return {
"""
JWT token visszafejtése és a típus (access) ellenőrzése.
"""
# Dev bypass (ha esetleg fejlesztéshez használtad korábban, itt a helye,
# de élesben a token validáció fut le)
if settings.DEBUG and token == "dev_bypass_active":
return {
"sub": "1",
"role": "superadmin",
"rank": 100,
"scope_level": "global",
"scope_id": "all"
"scope_id": "all",
"type": "access"
}
payload = decode_token(token)
if not payload:
if not payload or payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen vagy lejárt munkamenet."
@@ -33,39 +44,93 @@ async def get_current_token_payload(
return payload
async def get_current_user(
db: AsyncSession = Depends(get_db),
payload: Dict[str, Any] = Depends(get_current_token_payload),
db: AsyncSession = Depends(get_db),
payload: Dict = Depends(get_current_token_payload)
) -> User:
"""
Lekéri a felhasználót a token 'sub' mezője alapján.
"""
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 or user.is_deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="A felhasználó nem található.")
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
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""Ellenőrzi, hogy a felhasználó aktív-e."""
"""
Ellenőrzi, hogy a felhasználó aktív-e.
Ez elengedhetetlen az Admin felület és a védett végpontok számára.
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="A felhasználói fiók zárolva van vagy inaktív."
detail="A művelethez aktív profil és KYC azonosítás (Step 2) szükséges."
)
return current_user
def check_min_rank(required_rank: int):
def rank_checker(payload: Dict[str, Any] = Depends(get_current_token_payload)):
async def check_resource_access(
resource_scope_id: Union[str, int],
current_user: User = Depends(get_current_user)
):
"""
Scoped RBAC: Megakadályozza, hogy egy felhasználó más valaki erőforrásaihoz nyúljon.
Kezeli az ID-t (int) és a Scope ID-t / Slug-ot (str) is.
"""
if current_user.role == UserRole.superadmin:
return True
# Ha a usernek van beállított scope_id-ja (pl. egy flottához tartozik),
# akkor ellenőrizzük, hogy a kért erőforrás abba a scope-ba tartozik-e.
user_scope = current_user.scope_id
requested_scope = str(resource_scope_id)
# 1. Saját erőforrás (saját ID)
if str(current_user.id) == requested_scope:
return True
# 2. Scope alapú hozzáférés (pl. flotta tagja)
if user_scope and user_scope == requested_scope:
return True
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nincs jogosultsága ehhez az erőforráshoz."
)
def check_min_rank(role_key: str):
"""
Dinamikus Rank ellenőrzés.
Az adatbázisból (system_parameters) kéri le az elvárt szintet.
"""
async def rank_checker(
db: AsyncSession = Depends(get_db),
payload: Dict = Depends(get_current_token_payload)
):
# A settings.get_db_setting-et használjuk a dinamikus lekéréshez
ranks = await settings.get_db_setting(
db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP
)
required_rank = ranks.get(role_key, 0)
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})"
detail=f"Alacsony jogosultsági szint. (Szükséges: {required_rank})"
)
return True
return rank_checker

View File

@@ -1,11 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from authlib.integrations.starlette_client import OAuth
from app.db.session import get_db
from app.services.auth_service import AuthService
from app.core.security import create_access_token, RANK_MAP
from app.services.social_auth_service import SocialAuthService
from app.core.security import create_tokens, DEFAULT_RANK_MAP
from app.core.config import settings
from app.schemas.auth import (
UserLiteRegister, Token, PasswordResetRequest,
UserKYCComplete, PasswordResetConfirm
@@ -15,57 +19,50 @@ from app.models.identity import User
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ó. 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():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ez az e-mail cím már regisztrálva van."
)
try:
user = await AuthService.register_lite(db, user_in)
# 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",
"is_active": user.is_active
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Sikertelen regisztráció: {str(e)}"
)
# --- GOOGLE OAUTH KONFIGURÁCIÓ ---
oauth = OAuth()
oauth.register(
name='google',
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={'scope': 'openid email profile'}
)
@router.post("/login", response_model=Token)
async def login(
db: AsyncSession = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends()
):
"""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(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Hibás e-mail cím vagy jelszó."
)
# Szerepkör string kinyerése és rang meghatározása a RANK_MAP-ből
# --- SOCIAL AUTH ENDPOINTS ---
@router.get("/login/google")
async def login_google(request: Request):
"""
Step 1: Átirányítás a Google bejelentkező oldalára.
"""
redirect_uri = settings.GOOGLE_CALLBACK_URL
return await oauth.google.authorize_redirect(request, redirect_uri)
@router.get("/callback/google")
async def auth_google(request: Request, db: AsyncSession = Depends(get_db)):
"""
Step 2: Google visszahívás lekezelése + Dupla Token generálás.
"""
try:
token = await oauth.google.authorize_access_token(request)
user_info = token.get('userinfo')
except Exception:
raise HTTPException(status_code=400, detail="Google hitelesítési hiba.")
if not user_info:
raise HTTPException(status_code=400, detail="Nincs adat a Google-től.")
# Step 1: Technikai user létrehozása/keresése (inaktív, nincs mappa)
user = await SocialAuthService.get_or_create_social_user(
db, provider="google", social_id=user_info['sub'], email=user_info['email'],
first_name=user_info.get('given_name'), last_name=user_info.get('family_name')
)
# Dinamikus token generálás
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
user_rank = RANK_MAP.get(role_name, 10)
user_rank = ranks.get(role_name, 10)
token_data = {
"sub": str(user.id),
@@ -76,48 +73,104 @@ async def login(
"region": user.region_code
}
token = create_access_token(data=token_data)
access, refresh = create_tokens(data=token_data)
# Visszatérés a frontendre mindkét tokennel
response_url = f"{settings.FRONTEND_BASE_URL}/auth/callback?access={access}&refresh={refresh}"
return RedirectResponse(url=response_url)
# --- STANDARD AUTH ENDPOINTS ---
@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: Manuális regisztráció (inaktív, nincs mappa)."""
stmt = select(User).where(User.email == user_in.email)
if (await db.execute(stmt)).scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email már regisztrálva.")
user = await AuthService.register_lite(db, user_in)
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": ranks.get(role_name, 10),
"scope_level": "individual",
"scope_id": str(user.id),
"region": user.region_code
}
access, refresh = create_tokens(data=token_data)
return {
"access_token": token,
"access_token": access,
"refresh_token": refresh,
"token_type": "bearer",
"is_active": user.is_active
}
@router.post("/login", response_model=Token)
async def login(db: AsyncSession = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()):
"""Hagyományos belépés + Dupla Token."""
user = await AuthService.authenticate(db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=401, detail="Hibás adatok.")
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
user_rank = ranks.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
}
access, refresh = create_tokens(data=token_data)
return {
"access_token": access,
"refresh_token": refresh,
"token_type": "bearer",
"is_active": user.is_active
}
@router.get("/verify-email")
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
"""E-mail megerősítése."""
success = await AuthService.verify_email(db, token)
if not success:
raise HTTPException(status_code=400, detail="Érvénytelen vagy lejárt token.")
return {"message": "Email sikeresen megerősítve!"}
if not await AuthService.verify_email(db, token):
raise HTTPException(status_code=400, detail="Érvénytelen token.")
return {"message": "Email megerősítve!"}
@router.post("/complete-kyc")
async def complete_kyc(
kyc_in: UserKYCComplete,
db: AsyncSession = Depends(get_db),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Step 2: KYC adatok rögzítése és aktiválás."""
"""
Step 2: KYC Aktiválás.
Itt használjuk a get_current_user-t (nem active), mert a user még inaktív.
"""
user = await AuthService.complete_kyc(db, current_user.id, kyc_in)
if not user:
raise HTTPException(status_code=404, detail="Felhasználó nem található.")
return {"status": "success", "message": "A profil aktiválva."}
raise HTTPException(status_code=404, detail="User nem található.")
return {"status": "success", "message": "Fiók aktiválva."}
@router.post("/forgot-password")
async def forgot_password(req: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
"""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.")
return {"message": "Amennyiben a cím létezik, a linket kiküldtük."}
raise HTTPException(status_code=429, detail="Túl sok kérés.")
return {"message": "Visszaállító link kiküldve."}
@router.post("/reset-password")
async def reset_password(req: PasswordResetConfirm, db: AsyncSession = Depends(get_db)):
"""Új jelszó beállítása."""
if req.password != req.password_confirm:
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=400, detail="Hiba a jelszó frissítésekor.")
return {"message": "A jelszó sikeresen frissítve!"}
raise HTTPException(status_code=400, detail="Nem egyeznek a jelszavak.")
if not await AuthService.reset_password(db, req.email, req.token, req.password):
raise HTTPException(status_code=400, detail="Sikertelen frissítés.")
return {"message": "Jelszó frissítve!"}

View File

@@ -1,37 +1,46 @@
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from app.db.session import get_db
from app.models import AssetCatalog
from app.services.asset_service import AssetService
from typing import List
router = APIRouter()
@router.get("/search")
async def search_catalog(
q: str = Query(..., min_length=2, description="Márka vagy típus keresése"),
category: str = None,
db: AsyncSession = Depends(get_db)
):
"""Keresés a Robot által feltöltött katalógusban."""
stmt = select(VehicleCatalog).where(
or_(
VehicleCatalog.brand.ilike(f"%{q}%"),
VehicleCatalog.model.ilike(f"%{q}%")
)
)
if category:
stmt = stmt.where(VehicleCatalog.category == category)
result = await db.execute(stmt.limit(20))
items = result.scalars().all()
@router.get("/makes", response_model=List[str])
async def list_makes(db: AsyncSession = Depends(get_db)):
"""1. Szint: Márkák listázása."""
return await AssetService.get_makes(db)
@router.get("/models", response_model=List[str])
async def list_models(make: str, db: AsyncSession = Depends(get_db)):
"""2. Szint: Típusok listázása egy adott márkához."""
models = await AssetService.get_models(db, make)
if not models:
raise HTTPException(status_code=404, detail="Márka nem található vagy nincsenek típusok.")
return models
@router.get("/generations", response_model=List[str])
async def list_generations(make: str, model: str, db: AsyncSession = Depends(get_db)):
"""3. Szint: Generációk/Évjáratok listázása."""
generations = await AssetService.get_generations(db, make, model)
if not generations:
raise HTTPException(status_code=404, detail="Nincs generációs adat ehhez a típushoz.")
return generations
@router.get("/engines")
async def list_engines(make: str, model: str, gen: str, db: AsyncSession = Depends(get_db)):
"""4. Szint: Motorváltozatok és technikai specifikációk."""
engines = await AssetService.get_engines(db, make, model, gen)
if not engines:
raise HTTPException(status_code=404, detail="Nincs motorváltozat adat.")
# Itt visszaküldjük a teljes katalógus objektumokat (ID, motorváltozat, specifikációk)
return [
{
"id": i.id,
"full_name": f"{i.brand} {i.model}",
"category": i.category,
"status": i.verification_status,
"specs": i.factory_specs
} for i in items
"id": e.id,
"variant": e.engine_variant,
"engine_code": e.engine_code,
"fuel_type": e.fuel_type,
"factory_data": e.factory_data
} for e in engines
]

View File

@@ -6,21 +6,21 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
class Settings(BaseSettings):
# --- Paths (ÚJ SZEKCIÓ) ---
# Meghatározzuk a projekt gyökérmappáját és a statikus fájlok helyét
# --- Paths ---
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
STATIC_DIR: str = os.path.join(str(BASE_DIR), "static")
# --- General ---
PROJECT_NAME: str = "Traffic Ecosystem SuperApp"
VERSION: str = "1.0.0"
PROJECT_NAME: str = "Service Finder Ecosystem"
VERSION: str = "2.1.0"
API_V1_STR: str = "/api/v1"
DEBUG: bool = False
# --- Security / JWT ---
SECRET_KEY: str = "NOT_SET_DANGER"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 nap
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# --- Initial Admin ---
INITIAL_ADMIN_EMAIL: str = "admin@servicefinder.hu"
@@ -42,21 +42,35 @@ class Settings(BaseSettings):
SMTP_PASSWORD: Optional[str] = None
# --- External URLs ---
FRONTEND_BASE_URL: str = "http://localhost:3000"
FRONTEND_BASE_URL: str = "https://dev.profibot.hu"
# --- Dinamikus Admin Motor ---
# --- Google OAuth ---
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GOOGLE_CALLBACK_URL: str = "https://dev.profibot.hu/api/v1/auth/callback/google"
# --- Brute-Force & Security ---
LOGIN_RATE_LIMIT_ANON: str = "5/minute"
AUTH_MIN_PASSWORD_LENGTH: int = 8
# --- Dinamikus Admin Motor (Javított) ---
async def get_db_setting(self, db: AsyncSession, key_name: str, default: Any = None) -> Any:
"""
Lekér egy beállítást a data.system_parameters táblából.
Ha a tábla még nem létezik (migráció előtt), elkapja a hibát és default-ot ad.
"""
try:
query = text("SELECT value_json FROM data.system_settings WHERE key_name = :key")
# A lekérdezés a system_parameters táblát és a 'key' mezőt használja
query = text("SELECT value FROM data.system_parameters WHERE key = :key")
result = await db.execute(query, {"key": key_name})
row = result.fetchone()
if row and row[0] is not None:
return row[0]
return default
except Exception:
# Adatbázis hiba vagy hiányzó tábla esetén fallback az alapértelmezett értékre
return default
# .env fájl konfigurációja
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",

View File

@@ -1,65 +1,48 @@
import secrets
import string
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, Tuple
import bcrypt
from jose import jwt, JWTError
from app.core.config import settings
from fastapi_limiter import FastAPILimiter
from fastapi_limiter.depends import RateLimiter
# 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
# Ezt az auth végpontokhoz adjuk hozzá:
# @router.post("/login", dependencies=[Depends(RateLimiter(times=5, seconds=60))])
DEFAULT_RANK_MAP = {
"superadmin": 100, "admin": 80, "fleet_manager": 25,
"service": 15, "user": 10, "driver": 5
}
def generate_secure_slug(length: int = 12) -> str:
"""Biztonságos kód generálása (pl. mappákhoz)."""
alphabet = string.ascii_lowercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
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:
return False
if not hashed_password: return False
try:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8")
)
except Exception:
return False
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
except Exception: return False
def get_password_hash(password: str) -> str:
"""Létrehozza a jelszó hash-elt változatát."""
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
Létrehozza a JWT access tokent bővített RBAC adatokkal.
Várt kulcsok: sub (user_id), role, rank, scope_level, scope_id
"""
def create_tokens(data: Dict[str, Any], access_delta: Optional[timedelta] = None, refresh_delta: Optional[timedelta] = None) -> Tuple[str, str]:
"""Access és Refresh token generálása."""
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)
now = datetime.now(timezone.utc)
acc_min = access_delta if access_delta else timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_payload = {**to_encode, "exp": now + acc_min, "iat": now, "type": "access", "iss": "service-finder-auth"}
access_token = jwt.encode(access_payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
# 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
ref_days = refresh_delta if refresh_delta else timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_payload = {"sub": str(to_encode.get("sub")), "exp": now + ref_days, "iat": now, "type": "refresh"}
refresh_token = jwt.encode(refresh_payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return access_token, refresh_token
def decode_token(token: str) -> Optional[Dict[str, Any]]:
"""JWT token visszafejtése és validálása."""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
return None
try: return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
except JWTError: return None

View File

@@ -2,7 +2,8 @@ import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.v1.api import api_router # Ez már tartalmaz mindent (auth, services, stb.)
from starlette.middleware.sessions import SessionMiddleware # ÚJ
from app.api.v1.api import api_router
from app.core.config import settings
os.makedirs("static/previews", exist_ok=True)
@@ -14,12 +15,19 @@ app = FastAPI(
docs_url="/docs"
)
# --- SESSION MIDDLEWARE (Google Authhoz kötelező) ---
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY
)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://192.168.100.10:3001",
"http://localhost:3001",
"https://dev.profibot.hu"
"https://dev.profibot.hu",
"https://app.profibot.hu"
],
allow_credentials=True,
allow_methods=["*"],
@@ -27,8 +35,6 @@ app.add_middleware(
)
app.mount("/static", StaticFiles(directory="static"), name="static")
# CSAK EZT AZ EGYET KELL BEKÖTNI:
app.include_router(api_router, prefix="/api/v1")
@app.get("/")
@@ -36,5 +42,5 @@ async def root():
return {
"status": "online",
"message": "Service Finder Master System v2.0",
"features": ["Document Engine", "Asset Vault", "Org Onboarding", "Service Hunt"]
"features": ["Google Auth Enabled", "Asset Vault", "Org Onboarding"]
}

View File

@@ -1,34 +1,54 @@
# /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
# Identitás és Jogosultság
from .identity import User, Person, Wallet, UserRole, VerificationToken, SocialAccount
# Szervezeti struktúra
from .organization import Organization, OrganizationMember
# Járművek és Eszközök (Digital Twin)
from .asset import (
Asset, AssetCatalog, AssetCost, AssetEvent,
AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate
)
# Szerviz és Szakértelem (ÚJ)
from .service import ServiceProfile, ExpertiseTag, ServiceExpertise
# Földrajzi adatok és Címek
from .address import Address, GeoPostalCode, GeoStreet, GeoStreetType
# Gamification és Economy
from .gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, Rating, PointsLedger
# Rendszerkonfiguráció és Alapok
from .system_config import SystemParameter
from .document import Document
from .translation import Translation # <--- HOZZÁADVA
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
from .history import AuditLog, VehicleOwnership
from .security import PendingAction # <--- HOZZÁADVA
from .translation import Translation
# Aliasok
# Üzleti logika és Előfizetés
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
# Naplózás és Biztonság
from .history import AuditLog, VehicleOwnership
from .security import PendingAction
# Aliasok a kényelmesebb fejlesztéshez
Vehicle = Asset
UserVehicle = Asset
VehicleCatalog = AssetCatalog
ServiceRecord = AssetEvent
__all__ = [
"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", "Translation", "PendingAction", # <--- BŐVÍTVE
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount",
"Organization", "OrganizationMember",
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetFinancials",
"AssetTelemetry", "AssetReview", "ExchangeRate",
"ServiceProfile", "ExpertiseTag", "ServiceExpertise", # <--- HOZZÁADVA
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType",
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger",
"SystemParameter", "Document", "Translation", "PendingAction",
"SubscriptionTier", "OrganizationSubscription",
"CreditTransaction", "ServiceSpecialty", "AuditLog", "VehicleOwnership",
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord"

View File

@@ -6,42 +6,57 @@ from sqlalchemy.sql import func
from app.db.base_class import Base
class AssetCatalog(Base):
"""Globális járműkatalógus (Márka -> Típus -> Generáció -> Motor)."""
__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)
make = Column(String, index=True, nullable=False) # 1. Szint: Audi
model = Column(String, index=True, nullable=False) # 2. Szint: A4
generation = Column(String, index=True) # 3. Szint: B8 (2008-2015)
engine_variant = Column(String) # 4. Szint: 2.0 TDI (150 LE)
year_from = Column(Integer)
year_to = Column(Integer)
vehicle_class = Column(String)
fuel_type = Column(String)
engine_code = Column(String)
factory_data = Column(JSON, server_default=text("'{}'::jsonb"))
factory_data = Column(JSON, server_default=text("'{}'::jsonb")) # Technikai specifikációk
assets = relationship("Asset", back_populates="catalog")
class Asset(Base):
"""Egyedi jármű (Asset) példány - Az ökoszisztéma magja."""
__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)
# --- BIZTONSÁGI ÉS JOGOSULTSÁGI IZOLÁCIÓ ---
# A current_organization_id biztosítja a gyors, adatbázis-szintű Scoped RBAC védelmet.
current_organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=True)
catalog_id = Column(Integer, ForeignKey("data.vehicle_catalog.id"))
is_verified = Column(Boolean, default=False)
verification_method = Column(String(20)) # 'robot', 'ocr', 'manual'
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())
# Kapcsolatok (Digital Twin Modules)
catalog = relationship("AssetCatalog", back_populates="assets")
current_org = relationship("Organization")
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"
@@ -77,9 +92,10 @@ class AssetReview(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
asset = relationship("Asset", back_populates="reviews")
user = relationship("User") # <--- JAVÍTÁS: Hozzáadva
user = relationship("User")
class AssetAssignment(Base):
"""Jármű flotta-történetének nyilvántartása."""
__tablename__ = "asset_assignments"
__table_args__ = {"schema": "data"}
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@@ -90,7 +106,7 @@ class AssetAssignment(Base):
status = Column(String(30), default="active")
asset = relationship("Asset", back_populates="assignments")
organization = relationship("Organization") # <--- KRITIKUS JAVÍTÁS: Ez okozta a login hibát
organization = relationship("Organization")
class AssetEvent(Base):
__tablename__ = "asset_events"
@@ -113,16 +129,13 @@ class AssetCost(Base):
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())
mileage_at_cost = Column(Integer)
data = Column(JSON, server_default=text("'{}'::jsonb"))
asset = relationship("Asset", back_populates="costs")
organization = relationship("Organization") # <--- JAVÍTÁS: Hozzáadva
driver = relationship("User") # <--- JAVÍTÁS: Hozzáadva
organization = relationship("Organization")
driver = relationship("User")
class ExchangeRate(Base):
__tablename__ = "exchange_rates"
@@ -131,5 +144,4 @@ class ExchangeRate(Base):
base_currency = Column(String(3), default="EUR")
target_currency = Column(String(3), unique=True)
rate = Column(Numeric(18, 6), nullable=False)
rate_date = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey, text
from sqlalchemy.sql import func
from app.db.base_class import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("data.users.id", ondelete="SET NULL"), nullable=True)
action = Column(String(100), nullable=False) # pl. "LOGIN", "REGISTER", "DELETE_ASSET"
resource_type = Column(String(50)) # pl. "User", "Asset", "Organization"
resource_id = Column(String(100))
details = Column(JSON, server_default=text("'{}'::jsonb"))
ip_address = Column(String(45))
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,85 +1,68 @@
import uuid
import enum
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Enum, BigInteger
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Enum, BigInteger, UniqueConstraint
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 UserRole(str, enum.Enum):
superadmin = "superadmin"
admin = "admin"
user = "user"
service = "service"
fleet_manager = "fleet_manager"
driver = "driver"
superadmin = "superadmin"; admin = "admin"; user = "user"
service = "service"; fleet_manager = "fleet_manager"; driver = "driver"
class Person(Base):
__tablename__ = "persons"
__table_args__ = {"schema": "data"}
__tablename__ = "persons"; __table_args__ = {"schema": "data"}
id = Column(BigInteger, primary_key=True, index=True)
id_uuid = Column(PG_UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
address_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.addresses.id"), nullable=True)
last_name = Column(String, nullable=False)
first_name = Column(String, nullable=False)
phone = Column(String, nullable=True)
last_name = Column(String, nullable=False); first_name = Column(String, nullable=False); phone = Column(String, nullable=True)
mothers_last_name = Column(String); mothers_first_name = Column(String); birth_place = Column(String); birth_date = Column(DateTime)
identity_docs = Column(JSON, server_default=text("'{}'::jsonb"))
ice_contact = Column(JSON, server_default=text("'{}'::jsonb"))
is_active = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
users = relationship("User", back_populates="person")
class User(Base):
__tablename__ = "users"
__table_args__ = {"schema": "data"}
__tablename__ = "users"; __table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=True)
role = Column(Enum(UserRole), default=UserRole.user)
is_active = Column(Boolean, default=False)
is_deleted = Column(Boolean, default=False)
is_active = Column(Boolean, default=False); is_deleted = Column(Boolean, default=False)
person_id = Column(BigInteger, ForeignKey("data.persons.id"), nullable=True)
# ÚJ MEZŐK HOZZÁADVA:
preferred_language = Column(String(5), server_default="hu")
region_code = Column(String(5), server_default="HU")
# RBAC & SCOPE
scope_level = Column(String(30), server_default="individual")
scope_id = Column(String(50))
custom_permissions = Column(JSON, server_default=text("'{}'::jsonb"))
folder_slug = Column(String(12), unique=True, index=True)
refresh_token_hash = Column(String(255), nullable=True)
two_factor_secret = Column(String(100), nullable=True)
two_factor_enabled = Column(Boolean, default=False)
preferred_language = Column(String(5), server_default="hu"); region_code = Column(String(5), server_default="HU"); preferred_currency = Column(String(3), server_default="HUF")
scope_level = Column(String(30), server_default="individual"); scope_id = Column(String(50)); custom_permissions = Column(JSON, server_default=text("'{}'::jsonb"))
created_at = Column(DateTime(timezone=True), server_default=func.now())
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"); social_accounts = relationship("SocialAccount", back_populates="user", cascade="all, delete-orphan")
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")
# A Wallet és VerificationToken osztályok maradnak változatlanok...
class Wallet(Base):
__tablename__ = "wallets"
__table_args__ = {"schema": "data"}
__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")
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"}
__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)
token_type = Column(String(20), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False)
is_used = Column(Boolean, default=False)
token_type = Column(String(20), nullable=False); created_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False); is_used = Column(Boolean, default=False)
class SocialAccount(Base):
__tablename__ = "social_accounts"
__table_args__ = (UniqueConstraint('provider', 'social_id', name='uix_social_provider_id'), {"schema": "data"})
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("data.users.id", ondelete="CASCADE"), nullable=False)
provider = Column(String(50), nullable=False); social_id = Column(String(255), nullable=False, index=True); email = Column(String(255), nullable=False)
extra_data = Column(JSON, server_default=text("'{}'::jsonb")); created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="social_accounts")

View File

@@ -1,5 +1,4 @@
import enum
import uuid
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, text
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
from sqlalchemy.orm import relationship
@@ -25,6 +24,9 @@ class Organization(Base):
full_name = Column(String, nullable=False)
name = Column(String, nullable=False)
display_name = Column(String(50))
# --- BIZTONSÁGI BŐVÍTÉS (Mappa elszigetelés) ---
folder_slug = Column(String(12), unique=True, index=True)
default_currency = Column(String(3), default="HUF")
country_code = Column(String(2), default="HU")
@@ -63,7 +65,7 @@ class Organization(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# String alapú hivatkozás a körkörös import ellen
# Kapcsolatok
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")
@@ -75,8 +77,7 @@ class OrganizationMember(Base):
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")
permissions = Column(JSON, server_default=text("'{}'::jsonb"))
organization = relationship("Organization", back_populates="members")
user = relationship("User") # Egyszerűsített string hivatkozás
user = relationship("User")

View File

@@ -0,0 +1,59 @@
import uuid
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, text, Text
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from geoalchemy2 import Geometry # PostGIS támogatás
from sqlalchemy.sql import func
from app.db.base_class import Base
class ServiceProfile(Base):
"""
Szerviz szolgáltató kiterjesztett adatai.
Egy Organization-höz (org_type='service') kapcsolódik.
"""
__tablename__ = "service_profiles"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(Integer, ForeignKey("data.organizations.id"), unique=True)
# PostGIS GPS pont (SRID 4326 = WGS84 koordináták)
location = Column(Geometry(geometry_type='POINT', srid=4326), index=True)
# Trust Engine (Bot Discovery=30, User Entry=50, Admin/Partner=100)
trust_score = Column(Integer, default=30)
is_verified = Column(Boolean, default=False)
verification_log = Column(JSON, server_default=text("'{}'::jsonb"))
opening_hours = Column(JSON, server_default=text("'{}'::jsonb"))
contact_phone = Column(String)
website = Column(String)
bio = Column(Text)
# Kapcsolatok
organization = relationship("Organization")
expertises = relationship("ServiceExpertise", back_populates="service")
class ExpertiseTag(Base):
"""Szakmai szempontok taxonómiája."""
__tablename__ = "expertise_tags"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
key = Column(String(50), unique=True, index=True) # pl. 'bmw_gs_specialist'
name_hu = Column(String(100))
category = Column(String(30)) # 'repair', 'fuel', 'food', 'emergency'
class ServiceExpertise(Base):
"""Kapcsolótábla a szerviz és a szakterület között."""
__tablename__ = "service_expertises"
__table_args__ = {"schema": "data"}
service_id = Column(Integer, ForeignKey("data.service_profiles.id"), primary_key=True)
expertise_id = Column(Integer, ForeignKey("data.expertise_tags.id"), primary_key=True)
# Validációs szint (0-100% - Mennyire hiteles ez a szakértelem)
validation_level = Column(Integer, default=0)
service = relationship("ServiceProfile", back_populates="expertises")
expertise = relationship("ExpertiseTag")

View File

@@ -1,16 +1,18 @@
from sqlalchemy import Column, String, JSON, Integer, Boolean, DateTime, func
from sqlalchemy import Column, String, JSON, Boolean, DateTime, Integer, text
from sqlalchemy.sql import func
from app.db.base_class import Base
class SystemParameter(Base):
"""
Globális rendszerbeállítások (A meglévő data.system_parametersbla alapján).
Rendszerszintű dinamikus paraméterekrolása.
Szinkronban az admin.py és config.py elvárásaival.
"""
__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)
# Az admin.py 'key' mezőt vár, nem 'key_name'-et!
key = Column(String(50), primary_key=True, index=True)
value = Column(JSON, server_default=text("'{}'::jsonb"), nullable=False)
description = Column(String(255), nullable=True)
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,35 +1,99 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.asset import Asset, AssetTelemetry, AssetFinancials
from sqlalchemy import select, distinct
from app.models.asset import Asset, AssetCatalog, AssetTelemetry, AssetFinancials, AssetAssignment
from app.models.gamification import UserStats, PointRule
import uuid
import logging
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
logger = logging.getLogger(__name__)
# 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))
class AssetService:
@staticmethod
async def get_makes(db: AsyncSession):
"""1. Szint: Márkák lekérdezése (pl. Audi, BMW)."""
stmt = select(distinct(AssetCatalog.make)).order_by(AssetCatalog.make)
result = await db.execute(stmt)
return result.scalars().all()
# 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!
@staticmethod
async def get_models(db: AsyncSession, make: str):
"""2. Szint: Típusok szűrése márka alapján (pl. A4, A6)."""
stmt = select(distinct(AssetCatalog.model)).where(AssetCatalog.make == make).order_by(AssetCatalog.model)
result = await db.execute(stmt)
return result.scalars().all()
await db.commit()
return new_asset
@staticmethod
async def get_generations(db: AsyncSession, make: str, model: str):
"""3. Szint: Generációk/Évjáratok (pl. B8 (2008-2015))."""
stmt = select(distinct(AssetCatalog.generation)).where(
AssetCatalog.make == make,
AssetCatalog.model == model
).order_by(AssetCatalog.generation)
result = await db.execute(stmt)
return result.scalars().all()
@staticmethod
async def get_engines(db: AsyncSession, make: str, model: str, generation: str):
"""4. Szint: Motorváltozatok (pl. 2.0 TDI)."""
stmt = select(AssetCatalog).where(
AssetCatalog.make == make,
AssetCatalog.model == model,
AssetCatalog.generation == generation
).order_by(AssetCatalog.engine_variant)
result = await db.execute(stmt)
return result.scalars().all()
@staticmethod
async def create_and_assign_vehicle(
db: AsyncSession,
user_id: int,
org_id: int,
vin: str,
license_plate: str,
catalog_id: int = None
):
"""Jármű rögzítése, flottához rendelése és XP jóváírás (Atomic)."""
try:
# 1. Asset létrehozása közvetlen flotta-kötéssel
new_asset = Asset(
vin=vin,
license_plate=license_plate,
catalog_id=catalog_id,
current_organization_id=org_id, # Izolációs pointer
status="active",
is_verified=False
)
db.add(new_asset)
await db.flush()
# 2. Digitális Iker történetiség (Assignment)
assignment = AssetAssignment(
asset_id=new_asset.id,
organization_id=org_id,
status="active"
)
db.add(assignment)
# 3. Digitális Iker modulok indítása
db.add(AssetTelemetry(asset_id=new_asset.id))
db.add(AssetFinancials(asset_id=new_asset.id))
# 4. GAMIFICATION: XP jóváírás
rule_stmt = select(PointRule).where(PointRule.action_key == "ASSET_REGISTER")
rule = (await db.execute(rule_stmt)).scalar_one_or_none()
if rule:
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
logger.info(f"User {user_id} awarded {rule.points} XP for asset registration.")
# 5. Robot Scout Trigger (későbbi implementáció)
# await RobotScout.trigger_vin_lookup(db, new_asset.id)
await db.commit()
return new_asset
except Exception as e:
await db.rollback()
logger.error(f"Asset Creation Error: {str(e)}")
raise e

View File

@@ -9,12 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy.orm import joinedload
from fastapi.encoders import jsonable_encoder
from fastapi import HTTPException, status
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
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
from app.core.security import get_password_hash, verify_password, generate_secure_slug
from app.services.email_manager import email_manager
from app.core.config import settings
from app.services.config_service import config
@@ -26,8 +27,23 @@ logger = logging.getLogger(__name__)
class AuthService:
@staticmethod
async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
"""Step 1: Lite Regisztráció."""
"""
Step 1: Lite Regisztráció (Manuális).
Létrehozza a Person és User rekordokat, de a fiók inaktív marad.
A folder_slug itt még NEM generálódik le!
"""
try:
# --- Dinamikus jelszóhossz ellenőrzés ---
# Lekérjük az admin beállítást, minimum 8 karakter a hard limit.
min_pass = await config.get_setting(db, "auth_min_password_length", default=8)
min_len = max(int(min_pass), 8)
if len(user_in.password) < min_len:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"A jelszónak legalább {min_len} karakter hosszúnak kell lennie."
)
new_person = Person(
first_name=user_in.first_name,
last_name=user_in.last_name,
@@ -46,11 +62,13 @@ class AuthService:
region_code=user_in.region_code,
preferred_language=user_in.lang,
timezone=user_in.timezone
# folder_slug marad NULL a Step 2-ig
)
db.add(new_user)
await db.flush()
reg_hours = await config.get_setting("auth_registration_hours", region_code=user_in.region_code, default=48)
# Verifikációs token generálása
reg_hours = await config.get_setting(db, "auth_registration_hours", region_code=user_in.region_code, default=48)
token_val = uuid.uuid4()
db.add(VerificationToken(
token=token_val,
@@ -59,6 +77,7 @@ class AuthService:
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_hours))
))
# Email kiküldése
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
await email_manager.send_email(
recipient=user_in.email,
@@ -67,9 +86,23 @@ class AuthService:
lang=user_in.lang
)
# Audit log a regisztrációról
await security_service.log_event(
db,
user_id=new_user.id,
action="USER_REGISTER_LITE",
severity="info",
target_type="User",
target_id=str(new_user.id),
new_data={"email": user_in.email, "method": "manual"}
)
await db.commit()
await db.refresh(new_user)
return new_user
except HTTPException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
logger.error(f"Registration Error: {str(e)}")
@@ -77,16 +110,27 @@ class AuthService:
@staticmethod
async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete):
"""1.3. Fázis: Atomi Tranzakció & Shadow Identity."""
"""
Step 2: Atomi Tranzakció.
Itt dől el minden: Adatok rögzítése, Shadow Identity ellenőrzés,
Flotta és Wallet létrehozás, majd a fiók aktiválása.
"""
try:
stmt = select(User).options(joinedload(User.person)).where(User.id == user_id)
res = await db.execute(stmt)
user = res.scalar_one_or_none()
if not user: return None
# --- 1. BIZTONSÁG: User folder_slug generálása ---
# Ha Google-lel jött vagy még nincs slugja, most kap egyet.
if not user.folder_slug:
user.folder_slug = generate_secure_slug(length=12)
# Pénznem beállítása
if hasattr(kyc_in, 'preferred_currency') and kyc_in.preferred_currency:
user.preferred_currency = kyc_in.preferred_currency
# --- 2. Shadow Identity keresése (Már létezik-e ez a fizikai személy?) ---
identity_stmt = select(Person).where(and_(
Person.mothers_last_name == kyc_in.mothers_last_name,
Person.mothers_first_name == kyc_in.mothers_first_name,
@@ -96,12 +140,15 @@ class AuthService:
existing_person = (await db.execute(identity_stmt)).scalar_one_or_none()
if existing_person:
# Ha találtunk egyezést, összekötjük a User-t a meglévő Person-nel
user.person_id = existing_person.id
active_person = existing_person
logger.info(f"Shadow Identity linked: User {user_id} -> Person {existing_person.id}")
else:
# Ha nem, a saját (regisztrációkor létrehozott) Person-t töltjük fel
active_person = user.person
# --- 3. Cím rögzítése GeoService segítségével ---
addr_id = await GeoService.get_or_create_full_address(
db,
zip_code=kyc_in.address_zip,
@@ -112,31 +159,40 @@ class AuthService:
parcel_id=kyc_in.address_hrsz
)
# --- 4. Személyes 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
active_person.birth_date = kyc_in.birth_date
active_person.phone = kyc_in.phone_number
active_person.address_id = addr_id
# Dokumentumok és ICE kontakt mentése JSON-ként
active_person.identity_docs = jsonable_encoder(kyc_in.identity_docs)
active_person.ice_contact = jsonable_encoder(kyc_in.ice_contact)
# A Person most válik aktívvá
active_person.is_active = True
# --- 5. EGYÉNI FLOTTA LÉTREHOZÁSA (A KYC szerves része) ---
# Itt generáljuk a flotta mappáját is (folder_slug)
new_org = Organization(
full_name=f"{active_person.last_name} {active_person.first_name} Egyéni Flotta",
name=f"{active_person.last_name} Flotta",
folder_slug=generate_secure_slug(length=12), # FLOTTA SLUG
org_type=OrgType.individual,
owner_id=user.id,
is_transferable=False,
is_active=True,
status="verified",
language=user.preferred_language,
default_currency=user.preferred_currency,
default_currency=user.preferred_currency or "HUF",
country_code=user.region_code
)
db.add(new_org)
await db.flush()
# Flotta tagság (Owner)
db.add(OrganizationMember(
organization_id=new_org.id,
user_id=user.id,
@@ -144,15 +200,33 @@ class AuthService:
permissions={"can_add_asset": True, "can_view_costs": True, "is_admin": True}
))
# --- 6. PÉNZTÁRCA ÉS GAMIFICATION LÉTREHOZÁSA ---
db.add(Wallet(
user_id=user.id,
coin_balance=0,
credit_balance=0,
currency=user.preferred_currency
currency=user.preferred_currency or "HUF"
))
db.add(UserStats(user_id=user.id, total_xp=0, current_level=1))
# --- 7. AKTIVÁLÁS ÉS AUDIT ---
user.is_active = True
await security_service.log_event(
db,
user_id=user.id,
action="USER_KYC_COMPLETED",
severity="info",
target_type="User",
target_id=str(user.id),
new_data={
"status": "active",
"user_folder": user.folder_slug,
"organization_id": new_org.id,
"organization_folder": new_org.folder_slug,
"wallet_created": True
}
)
await db.commit()
await db.refresh(user)
@@ -165,8 +239,7 @@ class AuthService:
@staticmethod
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
"""
Step 2 utáni Soft-Delete: Email felszabadítás és izoláció.
Az email átnevezésre kerül, így az eredeti cím újra regisztrálható 'tiszta lappal'.
Soft-Delete: Email felszabadítás és izoláció.
"""
stmt = select(User).where(User.id == user_id)
user = (await db.execute(stmt)).scalar_one_or_none()
@@ -175,12 +248,11 @@ class AuthService:
return False
old_email = user.email
# Email felszabadítása: deleted_ID_TIMESTAMP_EMAIL formátumban
# Email átnevezése az egyediség megőrzése érdekében (újraregisztrációhoz)
user.email = f"deleted_{user.id}_{datetime.now().strftime('%Y%m%d')}_{old_email}"
user.is_deleted = True
user.is_active = False
# Sentinel AuditLog bejegyzés
await security_service.log_event(
db,
user_id=actor_id,
@@ -231,7 +303,7 @@ class AuthService:
user = (await db.execute(stmt)).scalar_one_or_none()
if user:
reset_hours = await config.get_setting("auth_password_reset_hours", region_code=user.region_code, default=2)
reset_hours = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2)
token_val = uuid.uuid4()
db.add(VerificationToken(
token=token_val,

View File

@@ -0,0 +1,61 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.models.service import ServiceProfile, ExpertiseTag, ServiceExpertise
from app.models.organization import Organization
from geoalchemy2.functions import ST_Distance, ST_MakePoint
class SearchService:
@staticmethod
async def find_nearby_services(
db: AsyncSession,
lat: float,
lon: float,
expertise_key: str = None,
radius_km: int = 50,
is_premium: bool = False
):
"""
Keresés távolság és szakértelem alapján.
Premium: Trust Score + Valós távolság.
Free: Trust Score + Légvonal.
"""
user_point = ST_MakePoint(lon, lat) # PostGIS pont létrehozása
# Alap lekérdezés: ServiceProfile + Organization adatok
stmt = select(ServiceProfile, Organization).join(
Organization, ServiceProfile.organization_id == Organization.id
)
# 1. Sugár alapú szűrés (radius_km * 1000 méter)
stmt = stmt.where(
func.ST_DWithin(ServiceProfile.location, user_point, radius_km * 1000)
)
# 2. Szakterület szűrése
if expertise_key:
stmt = stmt.join(ServiceProfile.expertises).join(ExpertiseTag).where(
ExpertiseTag.key == expertise_key
)
# 3. Távolság és Trust Score alapú sorrend
# A ST_Distance méterben adja vissza az eredményt
stmt = stmt.order_by(ST_Distance(ServiceProfile.location, user_point))
result = await db.execute(stmt.limit(50))
rows = result.all()
# Rangsorolási logika alkalmazása
results = []
for s_prof, org in rows:
results.append({
"id": org.id,
"name": org.full_name,
"trust_score": s_prof.trust_score,
"is_verified": s_prof.is_verified,
"phone": s_prof.contact_phone,
"website": s_prof.website,
"is_premium_partner": s_prof.trust_score >= 90
})
# Súlyozott rendezés: Prémium partnerek és Trust Score előre
return sorted(results, key=lambda x: (not is_premium, -x['trust_score']))

View File

@@ -0,0 +1,92 @@
import uuid
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.identity import User, Person, SocialAccount, UserRole
from app.services.security_service import security_service
logger = logging.getLogger(__name__)
class SocialAuthService:
@staticmethod
async def get_or_create_social_user(
db: AsyncSession,
provider: str,
social_id: str,
email: str,
first_name: str,
last_name: str
):
"""
Social Step 1: Csak alapregisztráció.
Nincs slug generálás, nincs flotta. Megáll a KYC kapujában.
"""
# 1. Meglévő Social kapcsolat ellenőrzése
stmt = select(SocialAccount).where(
SocialAccount.provider == provider,
SocialAccount.social_id == social_id
)
result = await db.execute(stmt)
social_acc = result.scalar_one_or_none()
if social_acc:
stmt = select(User).where(User.id == social_acc.user_id)
user_result = await db.execute(stmt)
return user_result.scalar_one_or_none()
# 2. Felhasználó keresése email alapján
stmt = select(User).where(User.email == email)
user_result = await db.execute(stmt)
user = user_result.scalar_one_or_none()
if not user:
try:
# Person rekord létrehozása a Google-től kapott nevekkel
new_person = Person(
id_uuid=uuid.uuid4(),
first_name=first_name or "Google",
last_name=last_name or "User",
is_active=False
)
db.add(new_person)
await db.flush()
# User rekord (folder_slug nélkül!)
user = User(
email=email,
hashed_password=None,
person_id=new_person.id,
role=UserRole.user,
is_active=False,
is_deleted=False,
preferred_language="hu",
region_code="HU"
)
db.add(user)
await db.flush()
await security_service.log_event(
db,
user_id=user.id,
action="USER_REGISTER_SOCIAL",
severity="info",
target_type="User",
target_id=str(user.id),
new_data={"email": email, "provider": provider}
)
except Exception as e:
await db.rollback()
logger.error(f"Social Registration Error: {str(e)}")
raise e
# 3. Összekötés
new_social = SocialAccount(
user_id=user.id,
provider=provider,
social_id=social_id,
email=email
)
db.add(new_social)
await db.commit()
await db.refresh(user)
return user

View File

@@ -0,0 +1,35 @@
# app/workers/catalog_filler.py
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import SessionLocal
from app.models.asset import AssetCatalog
from sqlalchemy import select
class CatalogFiller:
@staticmethod
async def seed_initial_data():
"""Alapértelmezett márkák és típusok feltöltése (Példa)."""
initial_data = [
{"make": "Audi", "model": "A4", "generation": "B8 (2008-2015)", "engine_variant": "2.0 TDI (150 LE)", "fuel_type": "Diesel"},
{"make": "BMW", "model": "3 Series", "generation": "F30 (2012-2019)", "engine_variant": "320d (190 LE)", "fuel_type": "Diesel"},
{"make": "Volkswagen", "model": "Passat", "generation": "B8 (2014-)", "engine_variant": "2.0 TDI (150 LE)", "fuel_type": "Diesel"}
]
async with SessionLocal() as db:
for item in initial_data:
# Ellenőrizzük, létezik-e már
stmt = select(AssetCatalog).where(
AssetCatalog.make == item["make"],
AssetCatalog.model == item["model"],
AssetCatalog.engine_variant == item["engine_variant"]
)
exists = (await db.execute(stmt)).scalar_one_or_none()
if not exists:
db.add(AssetCatalog(**item))
await db.commit()
print("Catalog seeding complete.")
if __name__ == "__main__":
asyncio.run(CatalogFiller.seed_initial_data())

View File

@@ -0,0 +1,60 @@
import asyncio
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.asset import AssetCatalog
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("Robot1-Catalog")
class CatalogScout:
"""
Robot 1: Járműkatalógus feltöltő.
Stratégia: Magyarországi alapok -> Globális EU márkák -> Technikai mélység.
"""
@staticmethod
async def get_initial_hu_data():
"""
Kezdeti adathalmaz (Példa).
Élesben itt egy külső API vagy CSV feldolgozás helye van.
"""
return [
# Suzuki - A magyar utak királya
{"make": "Suzuki", "model": "Swift", "generation": "III (2005-2010)", "engine_variant": "1.3 (92 LE)", "year_from": 2005, "year_to": 2010, "fuel_type": "petrol"},
{"make": "Suzuki", "model": "Vitara", "generation": "IV (2015-)", "engine_variant": "1.6 VVT (120 LE)", "year_from": 2015, "year_to": 2024, "fuel_type": "petrol"},
# Opel - Astra népautó
{"make": "Opel", "model": "Astra", "generation": "H (2004-2009)", "engine_variant": "1.4 Twinport (90 LE)", "year_from": 2004, "year_to": 2009, "fuel_type": "petrol"},
{"make": "Opel", "model": "Astra", "generation": "J (2009-2015)", "engine_variant": "1.7 CDTI (110 LE)", "year_from": 2009, "year_to": 2015, "fuel_type": "diesel"},
# Skoda - Családi/Flotta kedvenc
{"make": "Skoda", "model": "Octavia", "generation": "II (2004-2013)", "engine_variant": "1.6 MPI (102 LE)", "year_from": 2004, "year_to": 2013, "fuel_type": "petrol"},
{"make": "Skoda", "model": "Octavia", "generation": "III (2013-2020)", "engine_variant": "2.0 TDI (150 LE)", "year_from": 2013, "year_to": 2020, "fuel_type": "diesel"},
# BMW - GS Motorosoknak
{"make": "BMW", "model": "R 1200 GS", "generation": "K50 (2013-2018)", "engine_variant": "Adventure (125 LE)", "year_from": 2013, "year_to": 2018, "fuel_type": "petrol"}
]
@classmethod
async def run(cls):
logger.info("🤖 Robot 1 indítása: Járműkatalógus feltöltés...")
async with SessionLocal() as db:
data = await cls.get_initial_hu_data()
added_count = 0
for item in data:
# Ellenőrizzük az egyediséget (Make + Model + Generation + Engine)
stmt = select(AssetCatalog).where(
AssetCatalog.make == item["make"],
AssetCatalog.model == item["model"],
AssetCatalog.engine_variant == item["engine_variant"]
)
result = await db.execute(stmt)
if not result.scalar_one_or_none():
db.add(AssetCatalog(**item))
added_count += 1
await db.commit()
logger.info(f"✅ Robot 1 sikeresen rögzített {added_count} új katalógus elemet.")
if __name__ == "__main__":
asyncio.run(CatalogScout.run())