Initial commit - Migrated to Dev environment
This commit is contained in:
0
backend/app/__init__.py
Executable file
0
backend/app/__init__.py
Executable file
BIN
backend/app/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
backend/app/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/__pycache__/main.cpython-312.pyc
Executable file
BIN
backend/app/__pycache__/main.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/api/__pycache__/deps.cpython-312.pyc
Executable file
BIN
backend/app/api/__pycache__/deps.cpython-312.pyc
Executable file
Binary file not shown.
132
backend/app/api/auth.py
Executable file
132
backend/app/api/auth.py
Executable file
@@ -0,0 +1,132 @@
|
||||
from datetime import timedelta
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_token, decode_token
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@router.post("/login")
|
||||
def login(payload: Dict[str, Any]):
|
||||
"""
|
||||
payload:
|
||||
{
|
||||
"org_id": "<uuid>",
|
||||
"login": "<username or email>",
|
||||
"password": "<plain>"
|
||||
}
|
||||
"""
|
||||
from app.db.session import get_conn
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("BEGIN;")
|
||||
|
||||
org_id = (payload.get("org_id") or "").strip()
|
||||
login_id = (payload.get("login") or "").strip()
|
||||
password = payload.get("password") or ""
|
||||
|
||||
if not org_id or not login_id or not password:
|
||||
raise HTTPException(status_code=400, detail="org_id, login, password required")
|
||||
|
||||
# RLS miatt kötelező: org kontextus beállítás
|
||||
cur.execute("SELECT set_config('app.tenant_org_id', %s, false);", (org_id,))
|
||||
|
||||
# account + credential
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
a.account_id::text,
|
||||
a.org_id::text,
|
||||
a.username::text,
|
||||
a.email::text,
|
||||
c.password_hash,
|
||||
c.is_active
|
||||
FROM app.account a
|
||||
JOIN app.account_credential c ON c.account_id = a.account_id
|
||||
WHERE a.org_id = %s::uuid
|
||||
AND (a.username = %s::citext OR a.email = %s::citext)
|
||||
AND c.is_active = true
|
||||
LIMIT 1;
|
||||
""",
|
||||
(org_id, login_id, login_id),
|
||||
)
|
||||
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
account_id, org_id_db, username, email, password_hash, cred_active = row
|
||||
|
||||
# Jelszó ellenőrzés pgcrypto-val: crypt(plain, stored_hash) = stored_hash
|
||||
cur.execute("SELECT crypt(%s, %s) = %s;", (password, password_hash, password_hash))
|
||||
ok = cur.fetchone()[0]
|
||||
if not ok:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# MVP: role később membershipből; most fixen tenant_admin
|
||||
role_code = "tenant_admin"
|
||||
is_platform_admin = False
|
||||
|
||||
access = create_token(
|
||||
{
|
||||
"sub": account_id,
|
||||
"org_id": org_id_db,
|
||||
"role": role_code,
|
||||
"is_platform_admin": is_platform_admin,
|
||||
"type": "access",
|
||||
},
|
||||
settings.JWT_SECRET,
|
||||
timedelta(minutes=settings.JWT_ACCESS_MINUTES),
|
||||
)
|
||||
|
||||
refresh = create_token(
|
||||
{
|
||||
"sub": account_id,
|
||||
"org_id": org_id_db,
|
||||
"role": role_code,
|
||||
"is_platform_admin": is_platform_admin,
|
||||
"type": "refresh",
|
||||
},
|
||||
settings.JWT_SECRET,
|
||||
timedelta(days=settings.JWT_REFRESH_DAYS),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer"}
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
def refresh_token(payload: Dict[str, Any]):
|
||||
token = payload.get("refresh_token") or ""
|
||||
if not token:
|
||||
raise HTTPException(status_code=400, detail="refresh_token required")
|
||||
|
||||
try:
|
||||
claims = decode_token(token, settings.JWT_SECRET)
|
||||
if claims.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token type")
|
||||
|
||||
access = create_token(
|
||||
{
|
||||
"sub": claims.get("sub"),
|
||||
"org_id": claims.get("org_id"),
|
||||
"role": claims.get("role"),
|
||||
"is_platform_admin": claims.get("is_platform_admin", False),
|
||||
"type": "access",
|
||||
},
|
||||
settings.JWT_SECRET,
|
||||
timedelta(minutes=settings.JWT_ACCESS_MINUTES),
|
||||
)
|
||||
return {"access_token": access, "token_type": "bearer"}
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
39
backend/app/api/deps.py
Executable file
39
backend/app/api/deps.py
Executable file
@@ -0,0 +1,39 @@
|
||||
from typing import Generator
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.core.security import decode_token
|
||||
from app.models.user import User
|
||||
|
||||
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v2/auth/login")
|
||||
|
||||
async def get_db() -> Generator:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
async def get_current_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token: str = Depends(reusable_oauth2),
|
||||
) -> User:
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token error")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
res = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = res.scalars().first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Fiók nem aktív.")
|
||||
|
||||
return user
|
||||
14
backend/app/api/recommend.py
Executable file
14
backend/app/api/recommend.py
Executable file
@@ -0,0 +1,14 @@
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/provider/inbox")
|
||||
def provider_inbox(request: Request, provider_id: str):
|
||||
cur = request.state.db.cursor()
|
||||
cur.execute("""
|
||||
SELECT * FROM app.v_provider_inbox
|
||||
WHERE provider_listing_id = %s
|
||||
ORDER BY created_at DESC
|
||||
""", (provider_id,))
|
||||
rows = cur.fetchall()
|
||||
return rows
|
||||
BIN
backend/app/api/v1/__pycache__/api.cpython-312.pyc
Executable file
BIN
backend/app/api/v1/__pycache__/api.cpython-312.pyc
Executable file
Binary file not shown.
12
backend/app/api/v1/api.py
Executable file
12
backend/app/api/v1/api.py
Executable file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.endpoints import auth, users, vehicles, billing, fleet, expenses, reports
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(billing.router, prefix="/billing", tags=["billing"])
|
||||
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["vehicles"])
|
||||
api_router.include_router(fleet.router, prefix="/fleet", tags=["fleet"])
|
||||
api_router.include_router(expenses.router, prefix="/expenses", tags=["expenses"])
|
||||
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
|
||||
BIN
backend/app/api/v1/endpoints/__pycache__/auth.cpython-312.pyc
Executable file
BIN
backend/app/api/v1/endpoints/__pycache__/auth.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/api/v1/endpoints/__pycache__/billing.cpython-312.pyc
Executable file
BIN
backend/app/api/v1/endpoints/__pycache__/billing.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/api/v1/endpoints/__pycache__/expenses.cpython-312.pyc
Executable file
BIN
backend/app/api/v1/endpoints/__pycache__/expenses.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/api/v1/endpoints/__pycache__/fleet.cpython-312.pyc
Executable file
BIN
backend/app/api/v1/endpoints/__pycache__/fleet.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/api/v1/endpoints/__pycache__/reports.cpython-312.pyc
Executable file
BIN
backend/app/api/v1/endpoints/__pycache__/reports.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/api/v1/endpoints/__pycache__/users.cpython-312.pyc
Executable file
BIN
backend/app/api/v1/endpoints/__pycache__/users.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/api/v1/endpoints/__pycache__/vehicles.cpython-312.pyc
Executable file
BIN
backend/app/api/v1/endpoints/__pycache__/vehicles.cpython-312.pyc
Executable file
Binary file not shown.
79
backend/app/api/v1/endpoints/admin.py
Executable file
79
backend/app/api/v1/endpoints/admin.py
Executable file
@@ -0,0 +1,79 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api import deps
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.system_settings import SystemSetting # ÚJ import
|
||||
from app.models.gamification import PointRule, LevelConfig, RegionalSetting
|
||||
from app.models.translation import Translation
|
||||
from app.services.translation_service import TranslationService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def check_admin_access(current_user: User, required_roles: List[UserRole]):
|
||||
if current_user.role not in required_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nincs jogosultságod ehhez a művelethez."
|
||||
)
|
||||
|
||||
# --- ⚙️ ÚJ: DINAMIKUS RENDSZERBEÁLLÍTÁSOK (Pl. Jármű limit) ---
|
||||
|
||||
@router.get("/settings", response_model=List[dict])
|
||||
async def get_all_system_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
"""Az összes globális rendszerbeállítás listázása."""
|
||||
check_admin_access(current_user, [UserRole.SUPERUSER])
|
||||
result = await db.execute(select(SystemSetting))
|
||||
settings = result.scalars().all()
|
||||
return [{"key": s.key, "value": s.value, "description": s.description} for s in settings]
|
||||
|
||||
@router.put("/settings/{key}")
|
||||
async def update_system_setting(
|
||||
key: str,
|
||||
new_value: int, # Később lehet JSON is, ha komplexebb a beállítás
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
"""Egy adott beállítás (pl. FREE_VEHICLE_LIMIT) módosítása."""
|
||||
check_admin_access(current_user, [UserRole.SUPERUSER])
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if not setting:
|
||||
raise HTTPException(status_code=404, detail="Beállítás nem található")
|
||||
|
||||
setting.value = new_value
|
||||
await db.commit()
|
||||
return {"status": "success", "key": key, "new_value": new_value}
|
||||
|
||||
|
||||
# --- 🌍 FORDÍTÁSOK KEZELÉSE (Meglévő kódod) ---
|
||||
|
||||
@router.post("/translations", status_code=status.HTTP_201_CREATED)
|
||||
async def add_translation_draft(
|
||||
key: str, lang: str, value: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
check_admin_access(current_user, [UserRole.SUPERUSER, UserRole.REGIONAL_ADMIN])
|
||||
new_t = Translation(key=key, lang_code=lang, value=value, is_published=False)
|
||||
db.add(new_t)
|
||||
await db.commit()
|
||||
return {"message": "Fordítás piszkozatként mentve. Ne felejtsd el publikálni!"}
|
||||
|
||||
@router.post("/translations/publish")
|
||||
async def publish_translations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
check_admin_access(current_user, [UserRole.SUPERUSER, UserRole.REGIONAL_ADMIN])
|
||||
await TranslationService.publish_all(db)
|
||||
return {"message": "Sikeres publikálás! A változások minden szerveren élesedtek."}
|
||||
|
||||
91
backend/app/api/v1/endpoints/auth.py
Executable file
91
backend/app/api/v1/endpoints/auth.py
Executable file
@@ -0,0 +1,91 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib, secrets
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.core.security import get_password_hash
|
||||
from app.services.email_manager import email_manager
|
||||
from app.services.config_service import config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/register")
|
||||
async def register(
|
||||
request: Request,
|
||||
email: str,
|
||||
password: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ip = request.client.host
|
||||
|
||||
# 1. BOT-VÉDELEM
|
||||
throttle_min = await config.get_setting('registration_throttle_minutes', default=10)
|
||||
check_throttle = await db.execute(text("""
|
||||
SELECT count(*) FROM data.audit_logs
|
||||
WHERE ip_address = :ip AND action = 'USER_REGISTERED' AND created_at > :t
|
||||
"""), {'ip': ip, 't': datetime.utcnow() - timedelta(minutes=int(throttle_min))})
|
||||
|
||||
if check_throttle.scalar() > 0:
|
||||
raise HTTPException(status_code=429, detail="Túl sok próbálkozás. Várj pár percet!")
|
||||
|
||||
# 2. REGISZTRÁCIÓ
|
||||
res = await db.execute(select(User).where(User.email == email))
|
||||
if res.scalars().first():
|
||||
raise HTTPException(status_code=400, detail="Ez az email már foglalt.")
|
||||
|
||||
new_user = User(
|
||||
email=email,
|
||||
hashed_password=get_password_hash(password),
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
is_active=False
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
|
||||
# 3. TOKEN & LOG
|
||||
raw_token = secrets.token_urlsafe(48)
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
await db.execute(text("""
|
||||
INSERT INTO data.verification_tokens (user_id, token_hash, token_type, expires_at)
|
||||
VALUES (:u, :t, 'email_verify', :e)
|
||||
"""), {'u': new_user.id, 't': token_hash, 'e': datetime.utcnow() + timedelta(days=2)})
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO data.audit_logs (user_id, action, endpoint, method, ip_address)
|
||||
VALUES (:u, 'USER_REGISTERED', '/register', 'POST', :ip)
|
||||
"""), {'u': new_user.id, 'ip': ip})
|
||||
|
||||
# 4. EMAIL KÜLDÉS
|
||||
verify_link = f"http://{request.headers.get('host')}/api/v1/auth/verify?token={raw_token}"
|
||||
email_body = f"<h1>Szia {first_name}!</h1><p>Aktiváld a fiókod: <a href='{verify_link}'>{verify_link}</a></p>"
|
||||
|
||||
await email_manager.send_email(
|
||||
recipient=email,
|
||||
subject="Regisztráció megerősítése",
|
||||
body=email_body,
|
||||
email_type="registration",
|
||||
user_id=new_user.id
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Sikeres regisztráció! Ellenőrizd az email fiókodat."}
|
||||
|
||||
@router.get("/verify")
|
||||
async def verify_account(token: str, db: AsyncSession = Depends(get_db)):
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
query = text("SELECT user_id FROM data.verification_tokens WHERE token_hash = :t AND is_used = False")
|
||||
res = await db.execute(query, {'t': token_hash})
|
||||
row = res.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=400, detail="Érvénytelen aktiváló link")
|
||||
|
||||
await db.execute(text("UPDATE data.users SET is_active = True WHERE id = :id"), {'id': row[0]})
|
||||
await db.execute(text("UPDATE data.verification_tokens SET is_used = True WHERE token_hash = :t"), {'t': token_hash})
|
||||
await db.commit()
|
||||
return {"message": "Fiók aktiválva!"}
|
||||
125
backend/app/api/v1/endpoints/billing.py
Executable file
125
backend/app/api/v1/endpoints/billing.py
Executable file
@@ -0,0 +1,125 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from typing import List, Dict
|
||||
import secrets
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 1. EGYENLEG LEKÉRDEZÉSE (A felhasználó Széfjéhez kötve)
|
||||
@router.get("/balance")
|
||||
async def get_balance(db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
"""
|
||||
Visszaadja a felhasználó aktuális kreditegyenlegét és a Széfje (Cége) nevét.
|
||||
"""
|
||||
query = text("""
|
||||
SELECT
|
||||
uc.balance,
|
||||
c.name as company_name
|
||||
FROM data.user_credits uc
|
||||
JOIN data.companies c ON uc.user_id = c.owner_id
|
||||
WHERE uc.user_id = :user_id
|
||||
LIMIT 1
|
||||
""")
|
||||
result = await db.execute(query, {"user_id": current_user.id})
|
||||
row = result.fetchone()
|
||||
|
||||
if not row:
|
||||
return {
|
||||
"company_name": "Privát Széf",
|
||||
"balance": 0.0,
|
||||
"currency": "Credit"
|
||||
}
|
||||
|
||||
return {
|
||||
"company_name": row.company_name,
|
||||
"balance": float(row.balance),
|
||||
"currency": "Credit"
|
||||
}
|
||||
|
||||
# 2. TRANZAKCIÓS ELŐZMÉNYEK
|
||||
@router.get("/history")
|
||||
async def get_history(db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
"""
|
||||
Kilistázza a kreditmozgásokat (bevételek, költések, voucherek).
|
||||
"""
|
||||
query = text("""
|
||||
SELECT amount, reason, created_at
|
||||
FROM data.credit_transactions
|
||||
WHERE user_id = :user_id
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
result = await db.execute(query, {"user_id": current_user.id})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
# 3. VOUCHER BEVÁLTÁS (A rendszer gazdaságának motorja)
|
||||
@router.post("/vouchers/redeem")
|
||||
async def redeem_voucher(code: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
"""
|
||||
Bevált egy kódot, és jóváírja az értékét a felhasználó egyenlegén.
|
||||
"""
|
||||
# 1. Voucher ellenőrzése
|
||||
check_query = text("""
|
||||
SELECT id, value, is_used, expires_at
|
||||
FROM data.vouchers
|
||||
WHERE code = :code AND is_used = False AND (expires_at > now() OR expires_at IS NULL)
|
||||
""")
|
||||
res = await db.execute(check_query, {"code": code.strip().upper()})
|
||||
voucher = res.fetchone()
|
||||
|
||||
if not voucher:
|
||||
raise HTTPException(status_code=400, detail="Érvénytelen, lejárt vagy már felhasznált kód.")
|
||||
|
||||
# 2. Egyenleg frissítése (vagy létrehozása, ha még nincs sor a user_credits-ben)
|
||||
update_balance = text("""
|
||||
INSERT INTO data.user_credits (user_id, balance)
|
||||
VALUES (:u, :v)
|
||||
ON CONFLICT (user_id) DO UPDATE SET balance = data.user_credits.balance + :v
|
||||
""")
|
||||
await db.execute(update_balance, {"u": current_user.id, "v": voucher.value})
|
||||
|
||||
# 3. Tranzakció naplózása
|
||||
log_transaction = text("""
|
||||
INSERT INTO data.credit_transactions (user_id, amount, reason)
|
||||
VALUES (:u, :v, :r)
|
||||
""")
|
||||
await db.execute(log_transaction, {
|
||||
"u": current_user.id,
|
||||
"v": voucher.value,
|
||||
"r": f"Voucher beváltva: {code}"
|
||||
})
|
||||
|
||||
# 4. Voucher megjelölése felhasználtként
|
||||
await db.execute(text("""
|
||||
UPDATE data.vouchers
|
||||
SET is_used = True, used_by = :u, used_at = now()
|
||||
WHERE id = :vid
|
||||
"""), {"u": current_user.id, "vid": voucher.id})
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "added_value": float(voucher.value), "message": "Kredit jóváírva!"}
|
||||
|
||||
# 4. ADMIN: VOUCHER GENERÁLÁS (Csak Neked)
|
||||
@router.post("/vouchers/generate", include_in_schema=True)
|
||||
async def generate_vouchers(
|
||||
count: int = 1,
|
||||
value: float = 500.0,
|
||||
batch_name: str = "ADMIN_GEN",
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Tömeges voucher generálás az admin felületről.
|
||||
"""
|
||||
generated_codes = []
|
||||
for _ in range(count):
|
||||
# Generálunk egy SF-XXXX-XXXX formátumú kódot
|
||||
code = f"SF-{secrets.token_hex(3).upper()}-{secrets.token_hex(3).upper()}"
|
||||
await db.execute(text("""
|
||||
INSERT INTO data.vouchers (code, value, batch_id, expires_at)
|
||||
VALUES (:c, :v, :b, now() + interval '90 days')
|
||||
"""), {"c": code, "v": value, "b": batch_name})
|
||||
generated_codes.append(code)
|
||||
|
||||
await db.commit()
|
||||
return {"batch": batch_name, "count": count, "codes": generated_codes}
|
||||
51
backend/app/api/v1/endpoints/expenses.py
Executable file
51
backend/app/api/v1/endpoints/expenses.py
Executable file
@@ -0,0 +1,51 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from pydantic import BaseModel
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class ExpenseCreate(BaseModel):
|
||||
vehicle_id: str
|
||||
category: str # Pl: REFUELING, SERVICE, INSURANCE
|
||||
amount: float
|
||||
date: date
|
||||
odometer_value: Optional[float] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
@router.post("/add")
|
||||
async def add_expense(
|
||||
expense: ExpenseCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Új költség rögzítése egy járműhöz.
|
||||
"""
|
||||
# 1. Ellenőrizzük, hogy a jármű létezik-e
|
||||
query = text("SELECT id FROM data.vehicles WHERE id = :v_id")
|
||||
res = await db.execute(query, {"v_id": expense.vehicle_id})
|
||||
if not res.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Jármű nem található.")
|
||||
|
||||
# 2. Beszúrás a vehicle_expenses táblába
|
||||
insert_query = text("""
|
||||
INSERT INTO data.vehicle_expenses
|
||||
(vehicle_id, category, amount, date, odometer_value, description)
|
||||
VALUES (:v_id, :cat, :amt, :date, :odo, :desc)
|
||||
""")
|
||||
|
||||
await db.execute(insert_query, {
|
||||
"v_id": expense.vehicle_id,
|
||||
"cat": expense.category,
|
||||
"amt": expense.amount,
|
||||
"date": expense.date,
|
||||
"odo": expense.odometer_value,
|
||||
"desc": expense.description
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "message": "Költség rögzítve."}
|
||||
46
backend/app/api/v1/endpoints/fleet.py
Executable file
46
backend/app/api/v1/endpoints/fleet.py
Executable file
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.models.company import CompanyMember
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/vehicles")
|
||||
async def get_my_vehicles(db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
# Megkeressük a cégeket (széfeket), amikhez a felhasználónak köze van
|
||||
company_query = select(CompanyMember.company_id).where(CompanyMember.user_id == current_user.id)
|
||||
company_res = await db.execute(company_query)
|
||||
company_ids = company_res.scalars().all()
|
||||
|
||||
if not company_ids:
|
||||
return []
|
||||
|
||||
# Lekérjük az összes járművet, ami ezekhez a cégekhez tartozik
|
||||
query = select(Vehicle).where(Vehicle.current_company_id.in_(company_ids))
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/vehicles")
|
||||
async def add_vehicle(vehicle_in: dict, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
# Itt a meglévő logika fut tovább, de a Vehicle-t a user alapértelmezett cégéhez kötjük
|
||||
# Először lekérjük a user "owner" típusú cégét
|
||||
org_query = text("SELECT company_id FROM data.company_members WHERE user_id = :uid AND role = 'owner' LIMIT 1")
|
||||
org_res = await db.execute(org_query, {"uid": current_user.id})
|
||||
company_id = org_res.scalar()
|
||||
|
||||
if not company_id:
|
||||
raise HTTPException(status_code=404, detail="Nem található saját széf a jármű rögzítéséhez.")
|
||||
|
||||
# Új jármű létrehozása az új modell alapján
|
||||
new_vehicle = Vehicle(
|
||||
current_company_id=company_id,
|
||||
brand_id=vehicle_in.get("brand_id"),
|
||||
model_name=vehicle_in.get("model_name"),
|
||||
identification_number=vehicle_in.get("vin"),
|
||||
license_plate=vehicle_in.get("license_plate")
|
||||
)
|
||||
db.add(new_vehicle)
|
||||
await db.commit()
|
||||
return {"status": "success", "vehicle_id": str(new_vehicle.id)}
|
||||
54
backend/app/api/v1/endpoints/gamification.py
Executable file
54
backend/app/api/v1/endpoints/gamification.py
Executable file
@@ -0,0 +1,54 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api import deps
|
||||
from app.models.user import User
|
||||
from app.models.gamification import UserStats, UserBadge, Badge
|
||||
from app.schemas.social import UserStatSchema, BadgeSchema # Itt feltételezzük, hogy a sémákat már létrehoztad
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/my-stats", response_model=UserStatSchema)
|
||||
async def get_my_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
"""
|
||||
A bejelentkezett felhasználó aktuális pontszámának és szintjének lekérése.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(UserStats).where(UserStats.user_id == current_user.id)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if not stats:
|
||||
# Ha még nincs statisztika (új user), visszaadunk egy alapértelmezett kezdő állapotot
|
||||
return {
|
||||
"total_points": 0,
|
||||
"current_level": 1,
|
||||
"last_updated": None
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
@router.get("/my-badges", response_model=List[BadgeSchema])
|
||||
async def get_my_badges(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
"""
|
||||
A felhasználó által eddig megszerzett összes jelvény listázása.
|
||||
"""
|
||||
# Összekapcsoljuk a Badge és UserBadge táblákat
|
||||
query = (
|
||||
select(Badge.name, Badge.description, UserBadge.earned_at)
|
||||
.join(UserBadge, Badge.id == UserBadge.badge_id)
|
||||
.where(UserBadge.user_id == current_user.id)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
# Az .all() itt Tuple-öket ad vissza, amiket a Pydantic automatikusan validál
|
||||
return result.all()
|
||||
12
backend/app/api/v1/endpoints/providers.py
Executable file
12
backend/app/api/v1/endpoints/providers.py
Executable file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
from app.schemas.social import ServiceProviderCreate, ServiceProviderResponse
|
||||
from app.services.social_service import create_service_provider
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/", response_model=ServiceProviderResponse)
|
||||
async def add_provider(provider_data: ServiceProviderCreate, db: AsyncSession = Depends(get_db)):
|
||||
user_id = 2
|
||||
return await create_service_provider(db, provider_data, user_id)
|
||||
50
backend/app/api/v1/endpoints/reports.py
Executable file
50
backend/app/api/v1/endpoints/reports.py
Executable file
@@ -0,0 +1,50 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from app.api.deps import get_db, get_current_user
|
||||
|
||||
router = APIRouter() # EZ HIÁNYZOTT!
|
||||
|
||||
@router.get("/summary/{vehicle_id}")
|
||||
async def get_vehicle_summary(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
"""
|
||||
Összesített jelentés egy járműhöz: kategóriánkénti költségek.
|
||||
"""
|
||||
query = text("""
|
||||
SELECT
|
||||
category,
|
||||
SUM(amount) as total_amount,
|
||||
COUNT(*) as transaction_count
|
||||
FROM data.vehicle_expenses
|
||||
WHERE vehicle_id = :v_id
|
||||
GROUP BY category
|
||||
""")
|
||||
|
||||
result = await db.execute(query, {"v_id": vehicle_id})
|
||||
rows = result.fetchall()
|
||||
|
||||
total_cost = sum(row.total_amount for row in rows) if rows else 0
|
||||
|
||||
return {
|
||||
"vehicle_id": vehicle_id,
|
||||
"total_cost": float(total_cost),
|
||||
"breakdown": [dict(row._mapping) for row in rows]
|
||||
}
|
||||
|
||||
@router.get("/trends/{vehicle_id}")
|
||||
async def get_monthly_trends(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
"""
|
||||
Visszaadja az utolsó 6 hónap költéseit havi bontásban.
|
||||
"""
|
||||
query = text("""
|
||||
SELECT
|
||||
TO_CHAR(date, 'YYYY-MM') as month,
|
||||
SUM(amount) as monthly_total
|
||||
FROM data.vehicle_expenses
|
||||
WHERE vehicle_id = :v_id
|
||||
GROUP BY month
|
||||
ORDER BY month DESC
|
||||
LIMIT 6
|
||||
""")
|
||||
result = await db.execute(query, {"v_id": vehicle_id})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
72
backend/app/api/v1/endpoints/search.py
Executable file
72
backend/app/api/v1/endpoints/search.py
Executable file
@@ -0,0 +1,72 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.services.matching_service import matching_service
|
||||
from app.services.config_service import config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/match")
|
||||
async def match_service(
|
||||
lat: float,
|
||||
lng: float,
|
||||
radius: int = 20,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
# 1. SQL lekérdezés: Haversine-formula a távolság számításhoz
|
||||
# 6371 a Föld sugara km-ben
|
||||
query = text("""
|
||||
SELECT
|
||||
o.id,
|
||||
o.name,
|
||||
ol.latitude,
|
||||
ol.longitude,
|
||||
ol.label as location_name,
|
||||
(6371 * 2 * ASIN(SQRT(
|
||||
POWER(SIN((RADIANS(ol.latitude) - RADIANS(:lat)) / 2), 2) +
|
||||
COS(RADIANS(:lat)) * COS(RADIANS(ol.latitude)) *
|
||||
POWER(SIN((RADIANS(ol.longitude) - RADIANS(:lng)) / 2), 2)
|
||||
))) AS distance
|
||||
FROM data.organizations o
|
||||
JOIN data.organization_locations ol ON o.id = ol.organization_id
|
||||
WHERE o.org_type = 'SERVICE'
|
||||
AND o.is_active = True
|
||||
HAVING
|
||||
(6371 * 2 * ASIN(SQRT(
|
||||
POWER(SIN((RADIANS(ol.latitude) - RADIANS(:lat)) / 2), 2) +
|
||||
COS(RADIANS(:lat)) * COS(RADIANS(ol.latitude)) *
|
||||
POWER(SIN((RADIANS(ol.longitude) - RADIANS(:lng)) / 2), 2)
|
||||
))) <= :radius
|
||||
ORDER BY distance ASC
|
||||
""")
|
||||
|
||||
result = await db.execute(query, {"lat": lat, "lng": lng, "radius": radius})
|
||||
|
||||
# Adatok átalakítása a MatchingService számára (mock rating-et adunk hozzá, amíg nincs review tábla)
|
||||
services_to_rank = []
|
||||
for row in result.all():
|
||||
services_to_rank.append({
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"distance": row.distance,
|
||||
"rating": 4.5, # Alapértelmezett, amíg nincs kész az értékelési rendszer
|
||||
"tier": "gold" if row.id == 1 else "free" # Példa logika
|
||||
})
|
||||
|
||||
if not services_to_rank:
|
||||
return {"status": "no_results", "message": "Nem található szerviz a megadott körzetben."}
|
||||
|
||||
# 2. Limit lekérése a beállításokból
|
||||
limit = await config.get_setting('match_limit_default', default=5)
|
||||
|
||||
# 3. Okos rangsorolás (Admin súlyozás alapján)
|
||||
ranked_results = await matching_service.rank_services(services_to_rank)
|
||||
|
||||
return {
|
||||
"user_location": {"lat": lat, "lng": lng},
|
||||
"radius_km": radius,
|
||||
"results": ranked_results[:limit]
|
||||
}
|
||||
15
backend/app/api/v1/endpoints/social.py
Executable file
15
backend/app/api/v1/endpoints/social.py
Executable file
@@ -0,0 +1,15 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
from app.services.social_service import vote_for_provider, get_leaderboard
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/leaderboard")
|
||||
async def read_leaderboard(limit: int = 10, db: AsyncSession = Depends(get_db)):
|
||||
return await get_leaderboard(db, limit)
|
||||
|
||||
@router.post("/vote/{provider_id}")
|
||||
async def provider_vote(provider_id: int, vote_value: int, db: AsyncSession = Depends(get_db)):
|
||||
user_id = 2
|
||||
return await vote_for_provider(db, user_id, provider_id, vote_value)
|
||||
16
backend/app/api/v1/endpoints/users.py
Executable file
16
backend/app/api/v1/endpoints/users.py
Executable file
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.schemas.user import UserResponse
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def read_users_me(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Visszaadja a bejelentkezett felhasználó profilját"""
|
||||
return current_user
|
||||
23
backend/app/api/v1/endpoints/vehicle_search.py
Executable file
23
backend/app/api/v1/endpoints/vehicle_search.py
Executable file
@@ -0,0 +1,23 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.session import get_db
|
||||
from app.models.vehicle import VehicleBrand # Feltételezve, hogy létezik a modell
|
||||
from typing import List
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/search/brands")
|
||||
def search_brands(q: str = Query(..., min_length=2), db: Session = Depends(get_db)):
|
||||
# 1. KERESÉS A SAJÁT ADATBÁZISBAN
|
||||
results = db.query(VehicleBrand).filter(
|
||||
VehicleBrand.name.ilike(f"%{q}%"),
|
||||
VehicleBrand.is_active == True
|
||||
).limit(10).all()
|
||||
|
||||
# 2. HA NINCS TALÁLAT, INDÍTHATJUK A BOT-OT (Logikai váz)
|
||||
if not results:
|
||||
# Itt hívnánk meg a Discovery Bot-ot async módon
|
||||
# discovery_bot.find_brand_remotely(q)
|
||||
return {"status": "not_found", "message": "Nincs találat, a Bot elindult keresni...", "data": []}
|
||||
|
||||
return {"status": "success", "data": results}
|
||||
59
backend/app/api/v1/endpoints/vehicles.py
Executable file
59
backend/app/api/v1/endpoints/vehicles.py
Executable file
@@ -0,0 +1,59 @@
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from typing import List, Dict, Optional
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/search/brands")
|
||||
async def search_brands(q: str = Query(..., min_length=2), db: AsyncSession = Depends(get_db)):
|
||||
query = text("""
|
||||
SELECT id, name, slug, country_of_origin
|
||||
FROM data.vehicle_brands
|
||||
WHERE (name ILIKE :q OR slug ILIKE :q) AND is_active = true
|
||||
ORDER BY name ASC LIMIT 10
|
||||
""")
|
||||
# 1. Megvárjuk az execute-ot
|
||||
result = await db.execute(query, {"q": f"%{q}%"})
|
||||
# 2. Külön hívjuk a fetchall-t az eredményen
|
||||
rows = result.fetchall()
|
||||
|
||||
brand_list = [dict(row._mapping) for row in rows]
|
||||
if not brand_list:
|
||||
return {"status": "discovery_mode", "data": []}
|
||||
return {"status": "success", "data": brand_list}
|
||||
|
||||
@router.get("/search/providers")
|
||||
async def search_providers(q: str = Query(..., min_length=2), db: AsyncSession = Depends(get_db)):
|
||||
query = text("""
|
||||
SELECT id, name, technical_rating_pct, location_city, service_type
|
||||
FROM data.service_providers
|
||||
WHERE (name ILIKE :q OR service_type ILIKE :q) AND is_active = true
|
||||
ORDER BY technical_rating_pct DESC LIMIT 15
|
||||
""")
|
||||
result = await db.execute(query, {"q": f"%{q}%"})
|
||||
rows = result.fetchall()
|
||||
return {"status": "success", "data": [dict(row._mapping) for row in rows]}
|
||||
|
||||
@router.post("/register")
|
||||
async def register_user_vehicle(data: dict, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
company_res = await db.execute(text("SELECT id FROM data.companies WHERE owner_id = :u LIMIT 1"), {"u": current_user.id})
|
||||
company = company_res.fetchone()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="Széf nem található.")
|
||||
|
||||
new_vehicle = Vehicle(
|
||||
current_company_id=company.id,
|
||||
brand_id=data.get("brand_id"),
|
||||
model_name=data.get("model_name"),
|
||||
engine_spec_id=data.get("engine_spec_id"),
|
||||
identification_number=data.get("vin"),
|
||||
license_plate=data.get("plate"),
|
||||
tracking_mode=data.get("tracking_mode", "km"),
|
||||
total_real_usage=data.get("current_odo", 0)
|
||||
)
|
||||
db.add(new_vehicle)
|
||||
await db.commit()
|
||||
return {"status": "success", "vehicle_id": str(new_vehicle.id)}
|
||||
12
backend/app/api/v1/router.py
Executable file
12
backend/app/api/v1/router.py
Executable file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
import os
|
||||
import subprocess
|
||||
from app.api.v1 import social, users, fleet, auth
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
||||
api_router.include_router(social.router, prefix="/social", tags=["Social & Providers"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["Users"])
|
||||
api_router.include_router(fleet.router, prefix="/fleet", tags=["Fleet & Logistics"])
|
||||
BIN
backend/app/api/v2/__pycache__/auth.cpython-312.pyc
Executable file
BIN
backend/app/api/v2/__pycache__/auth.cpython-312.pyc
Executable file
Binary file not shown.
262
backend/app/api/v2/auth.py
Executable file
262
backend/app/api/v2/auth.py
Executable file
@@ -0,0 +1,262 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, status, Query
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash, verify_password, create_access_token
|
||||
from app.api.deps import get_db
|
||||
from app.models.user import User
|
||||
from app.models.company import Company
|
||||
from app.services.email_manager import email_manager
|
||||
|
||||
router = APIRouter(prefix="", tags=["Authentication V2"])
|
||||
|
||||
|
||||
# -----------------------
|
||||
# Pydantic request models
|
||||
# -----------------------
|
||||
class RegisterIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1, max_length=200) # policy endpointben
|
||||
first_name: str = Field(min_length=1, max_length=80)
|
||||
last_name: str = Field(min_length=1, max_length=80)
|
||||
|
||||
|
||||
class ResetPasswordIn(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
# -----------------------
|
||||
# Helpers
|
||||
# -----------------------
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _enforce_password_policy(password: str) -> None:
|
||||
# Most: teszt policy (min length), később bővítjük nagybetű/szám/special szabályokra
|
||||
min_len = int(getattr(settings, "PASSWORD_MIN_LENGTH", 4) or 4)
|
||||
if len(password) < min_len:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "password_policy_failed",
|
||||
"message": "A jelszó nem felel meg a biztonsági szabályoknak.",
|
||||
"rules": [f"Minimum hossz: {min_len} karakter"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _mark_token_used(db: AsyncSession, token_id: int) -> None:
|
||||
await db.execute(
|
||||
text(
|
||||
"UPDATE data.verification_tokens "
|
||||
"SET is_used = true, used_at = now() "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{"id": token_id},
|
||||
)
|
||||
|
||||
|
||||
# -----------------------
|
||||
# Endpoints
|
||||
# -----------------------
|
||||
@router.post("/register")
|
||||
async def register(payload: RegisterIn, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
_enforce_password_policy(payload.password)
|
||||
|
||||
# email unique (később: soft-delete esetén engedjük az újra-reget a szabályaid szerint)
|
||||
res = await db.execute(select(User).where(User.email == payload.email))
|
||||
if res.scalars().first():
|
||||
raise HTTPException(status_code=400, detail="Ez az e-mail cím már foglalt.")
|
||||
|
||||
# create inactive user
|
||||
new_user = User(
|
||||
email=payload.email,
|
||||
hashed_password=get_password_hash(payload.password),
|
||||
first_name=payload.first_name,
|
||||
last_name=payload.last_name,
|
||||
is_active=False,
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
|
||||
# create default private company
|
||||
new_company = Company(name=f"{payload.first_name} Privát Széfje", owner_id=new_user.id)
|
||||
db.add(new_company)
|
||||
await db.flush()
|
||||
|
||||
# membership (enum miatt raw SQL)
|
||||
await db.execute(
|
||||
text(
|
||||
"INSERT INTO data.company_members (company_id, user_id, role, is_active) "
|
||||
"VALUES (:c, :u, 'owner'::data.companyrole, true)"
|
||||
),
|
||||
{"c": new_company.id, "u": new_user.id},
|
||||
)
|
||||
|
||||
# verification token (store hash only)
|
||||
token = secrets.token_urlsafe(48)
|
||||
token_hash = _hash_token(token)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=48)
|
||||
|
||||
await db.execute(
|
||||
text(
|
||||
"INSERT INTO data.verification_tokens (user_id, token_hash, token_type, expires_at, is_used) "
|
||||
"VALUES (:u, :t, 'email_verify'::data.tokentype, :e, false)"
|
||||
),
|
||||
{"u": new_user.id, "t": token_hash, "e": expires_at},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send email (best-effort)
|
||||
try:
|
||||
link = f"{settings.FRONTEND_BASE_URL}/verify?token={token}"
|
||||
await email_manager.send_email(
|
||||
payload.email,
|
||||
"registration",
|
||||
{"first_name": payload.first_name, "link": link},
|
||||
user_id=new_user.id,
|
||||
)
|
||||
except Exception:
|
||||
# tesztben nem állítjuk meg a regisztrációt email hiba miatt
|
||||
pass
|
||||
|
||||
return {"message": "Sikeres regisztráció! Kérlek aktiváld az emailedet."}
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
|
||||
token_hash = _hash_token(token)
|
||||
|
||||
res = await db.execute(
|
||||
text(
|
||||
"SELECT id, user_id, expires_at, is_used "
|
||||
"FROM data.verification_tokens "
|
||||
"WHERE token_hash = :h "
|
||||
" AND token_type = 'email_verify'::data.tokentype "
|
||||
" AND is_used = false "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{"h": token_hash},
|
||||
)
|
||||
row = res.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=400, detail="Érvénytelen vagy már felhasznált token.")
|
||||
|
||||
# expired? -> mark used (audit) and reject
|
||||
if row.expires_at is not None and row.expires_at < datetime.now(timezone.utc):
|
||||
await _mark_token_used(db, row.id)
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=400, detail="A token lejárt. Kérj újat.")
|
||||
|
||||
# activate user
|
||||
await db.execute(
|
||||
text("UPDATE data.users SET is_active = true WHERE id = :u"),
|
||||
{"u": row.user_id},
|
||||
)
|
||||
|
||||
# mark used (one-time)
|
||||
await _mark_token_used(db, row.id)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Fiók aktiválva. Most már be tudsz jelentkezni."}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(
|
||||
text("SELECT id, hashed_password, is_active, is_superuser FROM data.users WHERE email = :e"),
|
||||
{"e": form_data.username},
|
||||
)
|
||||
u = res.fetchone()
|
||||
|
||||
if not u or not verify_password(form_data.password, u.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Hibás hitelesítés.")
|
||||
|
||||
if not u.is_active:
|
||||
raise HTTPException(status_code=400, detail="Fiók nem aktív.")
|
||||
|
||||
token = create_access_token({"sub": str(u.id), "is_admin": bool(u.is_superuser)})
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password(email: EmailStr = Query(...), db: AsyncSession = Depends(get_db)):
|
||||
# Anti-enumeration: mindig ugyanazt válaszoljuk
|
||||
res = await db.execute(select(User).where(User.email == email))
|
||||
user = res.scalars().first()
|
||||
|
||||
if user:
|
||||
token = secrets.token_urlsafe(48)
|
||||
token_hash = _hash_token(token)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=2)
|
||||
|
||||
await db.execute(
|
||||
text(
|
||||
"INSERT INTO data.verification_tokens (user_id, token_hash, token_type, expires_at, is_used) "
|
||||
"VALUES (:u, :t, 'password_reset'::data.tokentype, :e, false)"
|
||||
),
|
||||
{"u": user.id, "t": token_hash, "e": expires_at},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token}"
|
||||
try:
|
||||
await email_manager.send_email(
|
||||
email,
|
||||
"password_reset",
|
||||
{"first_name": user.first_name or "", "link": link},
|
||||
user_id=user.id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"message": "Ha a cím létezik, a helyreállító levelet elküldtük."}
|
||||
|
||||
|
||||
@router.post("/reset-password-confirm")
|
||||
async def reset_password_confirm(payload: ResetPasswordIn, db: AsyncSession = Depends(get_db)):
|
||||
_enforce_password_policy(payload.new_password)
|
||||
|
||||
token_hash = _hash_token(payload.token)
|
||||
|
||||
res = await db.execute(
|
||||
text(
|
||||
"SELECT id, user_id, expires_at, is_used "
|
||||
"FROM data.verification_tokens "
|
||||
"WHERE token_hash = :h "
|
||||
" AND token_type = 'password_reset'::data.tokentype "
|
||||
" AND is_used = false "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{"h": token_hash},
|
||||
)
|
||||
row = res.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=400, detail="Érvénytelen vagy már felhasznált token.")
|
||||
|
||||
if row.expires_at is not None and row.expires_at < datetime.now(timezone.utc):
|
||||
await _mark_token_used(db, row.id)
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=400, detail="A token lejárt. Kérj újat.")
|
||||
|
||||
new_hash = get_password_hash(payload.new_password)
|
||||
|
||||
await db.execute(
|
||||
text("UPDATE data.users SET hashed_password = :p WHERE id = :u"),
|
||||
{"p": new_hash, "u": row.user_id},
|
||||
)
|
||||
|
||||
await _mark_token_used(db, row.id)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Jelszó sikeresen megváltoztatva."}
|
||||
240
backend/app/auth/router.py
Executable file
240
backend/app/auth/router.py
Executable file
@@ -0,0 +1,240 @@
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, Header
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from passlib.context import CryptContext
|
||||
from jose import JWTError, jwt
|
||||
import redis.asyncio as redis
|
||||
|
||||
# --- KONFIGURÁCIÓ ---
|
||||
DATABASE_URL = "postgresql+asyncpg://user:password@localhost/service_finder_db"
|
||||
REDIS_URL = "redis://localhost:6379"
|
||||
SECRET_KEY = "szuper_titkos_jwt_kulcs_amit_env_bol_kellene_olvasni"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
# --- ADATBÁZIS SETUP (SQLAlchemy 2.0) ---
|
||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
# --- REDIS SETUP ---
|
||||
redis_client = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
|
||||
# --- SECURITY UTILS ---
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v2/auth/login")
|
||||
|
||||
class ClientType(str, Enum):
|
||||
WEB = "web"
|
||||
MOBILE = "mobile"
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_token(data: dict, expires_delta: timedelta):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
# --- PYDANTIC SCHEMAS ---
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: EmailStr
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str # OAuth2 form compatibility miatt username, de emailt várunk
|
||||
password: str
|
||||
client_type: ClientType # 'web' vagy 'mobile'
|
||||
|
||||
# --- ÜZLETI LOGIKA & ROUTER ---
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
async def register(user: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
# 1. Email ellenőrzése
|
||||
stmt = select(User).where(User.email == user.email)
|
||||
result = await db.execute(stmt)
|
||||
if result.scalars().first():
|
||||
raise HTTPException(status_code=400, detail="Ez az email cím már regisztrálva van.")
|
||||
|
||||
# 2. User létrehozása (inaktív)
|
||||
hashed_pwd = get_password_hash(user.password)
|
||||
new_user = User(email=user.email, password_hash=hashed_pwd, is_active=False)
|
||||
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
|
||||
# Itt kellene elküldeni az emailt a verify linkkel (most szimuláljuk)
|
||||
return new_user
|
||||
|
||||
@router.get("/verify/{token}")
|
||||
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
|
||||
# Megjegyzés: A valóságban a token-t dekódolni kellene, hogy kinyerjük a user ID-t.
|
||||
# Most szimuláljuk, hogy a token valójában a user email-címe base64-ben vagy hasonló.
|
||||
# Egyszerűsítés a példa kedvéért: feltételezzük, hogy a token = user_id
|
||||
|
||||
try:
|
||||
user_id = int(token) # DEMO ONLY
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Felhasználó nem található")
|
||||
|
||||
user.is_active = True
|
||||
await db.commit()
|
||||
return {"message": "Fiók sikeresen aktiválva!"}
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Érvénytelen token")
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
client_type: ClientType = ClientType.WEB, # Query param vagy form field
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Kritikus Redis Session Limitáció implementációja.
|
||||
"""
|
||||
# 1. User keresése
|
||||
stmt = select(User).where(User.email == form_data.username)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Hibás email vagy jelszó")
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="A fiók még nincs aktiválva.")
|
||||
|
||||
# 2. Token generálás
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
refresh_token_expires = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
# A tokenbe beleégetjük a client_type-ot is, hogy validálásnál ellenőrizhessük
|
||||
token_data = {"sub": str(user.id), "client_type": client_type.value}
|
||||
|
||||
access_token = create_token(token_data, access_token_expires)
|
||||
refresh_token = create_token({"sub": str(user.id), "type": "refresh"}, refresh_token_expires)
|
||||
|
||||
# 3. REDIS SESSION KEZELÉS (A feladat kritikus része)
|
||||
# Kulcs formátum: session:{user_id}:{client_type} -> access_token
|
||||
session_key = f"session:{user.id}:{client_type.value}"
|
||||
|
||||
# A Redis 'SET' parancsa felülírja a kulcsot, ha az már létezik.
|
||||
# Ez megvalósítja a "Logout other devices" logikát az AZONOS típusú eszközökre.
|
||||
# Ezzel egy időben, mivel a kulcs tartalmazza a típust (web/mobile),
|
||||
# garantáljuk, hogy max 1 web és 1 mobile lehet (külön kulcsok).
|
||||
|
||||
await redis_client.set(
|
||||
name=session_key,
|
||||
value=access_token,
|
||||
ex=ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer"
|
||||
}
|
||||
|
||||
# --- MIDDLEWARE / DEPENDENCY TOKEN ELLENŐRZÉSHEZ ---
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Nem sikerült hitelesíteni a felhasználót",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id: str = payload.get("sub")
|
||||
client_type: str = payload.get("client_type")
|
||||
|
||||
if user_id is None or client_type is None:
|
||||
raise credentials_exception
|
||||
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
# KRITIKUS: Token validálása Redis ellenében (Stateful JWT)
|
||||
# Ha a Redisben lévő token nem egyezik a küldött tokennel,
|
||||
# akkor a felhasználót kijelentkeztették egy másik eszközről.
|
||||
session_key = f"session:{user_id}:{client_type}"
|
||||
stored_token = await redis_client.get(session_key)
|
||||
|
||||
if stored_token != token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="A munkamenet lejárt vagy egy másik eszközről beléptek."
|
||||
)
|
||||
|
||||
stmt = select(User).where(User.id == int(user_id))
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalars().first()
|
||||
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
# --- MAIN APP ---
|
||||
app = FastAPI(title="Service Finder API")
|
||||
app.include_router(router)
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Service Finder API fut"}
|
||||
|
||||
@app.get("/protected-route")
|
||||
async def protected(user: User = Depends(get_current_user)):
|
||||
|
||||
return {"message": f"Szia {user.email}, érvényes a munkameneted!"}
|
||||
|
||||
0
backend/app/core/__init__.py
Executable file
0
backend/app/core/__init__.py
Executable file
BIN
backend/app/core/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
backend/app/core/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/core/__pycache__/config.cpython-312.pyc
Executable file
BIN
backend/app/core/__pycache__/config.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/core/__pycache__/security.cpython-312.pyc
Executable file
BIN
backend/app/core/__pycache__/security.cpython-312.pyc
Executable file
Binary file not shown.
53
backend/app/core/config.py
Executable file
53
backend/app/core/config.py
Executable file
@@ -0,0 +1,53 @@
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import computed_field
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# --- General ---
|
||||
PROJECT_NAME: str = "Traffic Ecosystem SuperApp"
|
||||
VERSION: str = "2.0.0"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
DEBUG: bool = False
|
||||
|
||||
# --- Security / JWT ---
|
||||
SECRET_KEY: str
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
|
||||
# --- Password policy (TEST -> laza, PROD -> szigorú) ---
|
||||
PASSWORD_MIN_LENGTH: int = 4 # TESZT: 4, ÉLES: 10-12
|
||||
|
||||
# --- Database ---
|
||||
DATABASE_URL: str # már nálad compose-ban meg van adva
|
||||
|
||||
# --- Redis ---
|
||||
REDIS_URL: str = "redis://service_finder_redis:6379/0"
|
||||
|
||||
# --- Email sending ---
|
||||
# auto = ha van SENDGRID_API_KEY -> sendgrid api, különben smtp
|
||||
EMAIL_PROVIDER: str = "auto" # auto | sendgrid | smtp | disabled
|
||||
|
||||
EMAILS_FROM_EMAIL: str = "info@profibot.hu"
|
||||
EMAILS_FROM_NAME: str = "Profibot"
|
||||
|
||||
# SendGrid API
|
||||
SENDGRID_API_KEY: Optional[str] = None
|
||||
|
||||
# SMTP fallback (pl. Gmail App Password vagy más szolgáltató)
|
||||
SMTP_HOST: Optional[str] = None
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASSWORD: Optional[str] = None
|
||||
SMTP_USE_TLS: bool = True
|
||||
|
||||
# Frontend base URL a linkekhez (később NPM/domain)
|
||||
FRONTEND_BASE_URL: str = "http://192.168.100.43:3000"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra="ignore"
|
||||
)
|
||||
|
||||
settings = Settings()
|
||||
10
backend/app/core/email.py
Executable file
10
backend/app/core/email.py
Executable file
@@ -0,0 +1,10 @@
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def send_verification_email(email_to: str, token: str, first_name: str):
|
||||
logger.info(f"MOCK EMAIL -> Címzett: {email_to}, Token: {token}")
|
||||
return True
|
||||
|
||||
async def send_new_account_email(email_to: str, username: str, password: str):
|
||||
logger.info(f"MOCK EMAIL -> Új fiók: {username}")
|
||||
return True
|
||||
18
backend/app/core/email.py.bak
Executable file
18
backend/app/core/email.py.bak
Executable file
@@ -0,0 +1,18 @@
|
||||
import os
|
||||
from sendgrid import SendGridAPIClient
|
||||
from sendgrid.helpers.mail import Mail
|
||||
|
||||
def send_verification_email(to_email: str, token: str):
|
||||
message = Mail(
|
||||
from_email='noreply@servicefinder.pro', # Ezt majd igazítsd a SendGrid verified senderhez
|
||||
to_emails=to_email,
|
||||
subject='Service Finder - Regisztráció megerősítése',
|
||||
html_content=f'<h3>Üdvözöljük a Service Finderben!</h3><p>A regisztráció befejezéséhez kattintson az alábbi linkre:</p><p><a href="https://servicefinder.pro/verify?token={token}">Megerősítem a regisztrációmat</a></p>'
|
||||
)
|
||||
try:
|
||||
sg = SendGridAPIClient(os.environ.get('SENDGRID_API_KEY'))
|
||||
response = sg.send(message)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Email hiba: {e}")
|
||||
return False
|
||||
33
backend/app/core/security.py
Executable file
33
backend/app/core/security.py
Executable file
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import bcrypt
|
||||
from jose import jwt, JWTError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# --- JELSZÓ ---
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
try:
|
||||
if not hashed_password:
|
||||
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:
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
|
||||
|
||||
# --- JWT ---
|
||||
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = dict(data)
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
def decode_token(token: str) -> Dict[str, Any]:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
0
backend/app/crud/__init__.py
Executable file
0
backend/app/crud/__init__.py
Executable file
11
backend/app/database.py
Executable file
11
backend/app/database.py
Executable file
@@ -0,0 +1,11 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
# A .env fájlból olvassuk majd, de teszthez:
|
||||
DATABASE_URL = "postgresql+asyncpg://user:password@db_container_name:5432/db_name"
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||
SessionLocal = async_sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession)
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
0
backend/app/db/__init__.py
Executable file
0
backend/app/db/__init__.py
Executable file
BIN
backend/app/db/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
backend/app/db/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/db/__pycache__/base.cpython-312.pyc
Executable file
BIN
backend/app/db/__pycache__/base.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/db/__pycache__/session.cpython-312.pyc
Executable file
BIN
backend/app/db/__pycache__/session.cpython-312.pyc
Executable file
Binary file not shown.
10
backend/app/db/base.py
Executable file
10
backend/app/db/base.py
Executable file
@@ -0,0 +1,10 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(AsyncAttrs, DeclarativeBase):
|
||||
"""
|
||||
Base class for all SQLAlchemy models.
|
||||
Includes AsyncAttrs to support async attribute access (lazy loading).
|
||||
"""
|
||||
pass
|
||||
38
backend/app/db/context.py
Executable file
38
backend/app/db/context.py
Executable file
@@ -0,0 +1,38 @@
|
||||
from typing import Generator, Optional, Dict, Any
|
||||
from fastapi import Request
|
||||
from app.db.session import get_conn
|
||||
|
||||
def _set_config(cur, key: str, value: str) -> None:
|
||||
cur.execute("SELECT set_config(%s, %s, false);", (key, value))
|
||||
|
||||
def db_tx(request: Request) -> Generator[Dict[str, Any], None, None]:
|
||||
"""
|
||||
Egységes DB tranzakció + session context:
|
||||
BEGIN
|
||||
set_config(app.tenant_org_id, app.account_id, app.is_platform_admin)
|
||||
COMMIT/ROLLBACK
|
||||
"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("BEGIN;")
|
||||
|
||||
claims: Optional[dict] = getattr(request.state, "claims", None)
|
||||
if claims:
|
||||
org_id = claims.get("org_id") or ""
|
||||
account_id = claims.get("sub") or ""
|
||||
is_platform_admin = claims.get("is_platform_admin", False)
|
||||
|
||||
# Fontos: set_config stringeket vár
|
||||
_set_config(cur, "app.tenant_org_id", str(org_id))
|
||||
_set_config(cur, "app.account_id", str(account_id))
|
||||
_set_config(cur, "app.is_platform_admin", "true" if is_platform_admin else "false")
|
||||
|
||||
yield {"conn": conn, "cur": cur}
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
31
backend/app/db/middleware.py
Executable file
31
backend/app/db/middleware.py
Executable file
@@ -0,0 +1,31 @@
|
||||
from fastapi import Request
|
||||
from app.db.session import SessionLocal
|
||||
from app.services.config_service import config
|
||||
from sqlalchemy import text
|
||||
import json
|
||||
|
||||
async def audit_log_middleware(request: Request, call_next):
|
||||
logging_enabled = await config.get_setting('audit_log_enabled', default=True)
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
if logging_enabled and request.method != 'GET': # GET-et általában nem naplózunk a zaj miatt, de állítható
|
||||
try:
|
||||
user_id = getattr(request.state, 'user_id', None) # Ha már be van lépve
|
||||
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(text("""
|
||||
INSERT INTO data.audit_logs (user_id, action, endpoint, method, ip_address)
|
||||
VALUES (:u, :a, :e, :m, :ip)
|
||||
"""), {
|
||||
'u': user_id,
|
||||
'a': f'API_CALL_{request.method}',
|
||||
'e': str(request.url.path),
|
||||
'm': request.method,
|
||||
'ip': request.client.host
|
||||
})
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass # A naplózás hibája nem akaszthatja meg a kiszolgálást
|
||||
|
||||
return response
|
||||
31
backend/app/db/session.py
Executable file
31
backend/app/db/session.py
Executable file
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
from typing import AsyncGenerator
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL, # A te eredeti kulcsod
|
||||
echo=getattr(settings, "DEBUG", False),
|
||||
future=True,
|
||||
pool_size=20,
|
||||
max_overflow=10
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False
|
||||
)
|
||||
# Ez a sor kell, mert a main.py és a többiek ezen a néven keresik
|
||||
SessionLocal = AsyncSessionLocal
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
37
backend/app/final_admin_fix.py
Executable file
37
backend/app/final_admin_fix.py
Executable file
@@ -0,0 +1,37 @@
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.models.user import User, UserRole
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
async def run_fix():
|
||||
async with SessionLocal() as db:
|
||||
# 1. Ellenőrizzük az oszlopokat (biztonsági játék)
|
||||
res = await db.execute(text("SELECT column_name FROM information_schema.columns WHERE table_schema = \u0027data\u0027 AND table_name = \u0027users\u0027"))
|
||||
cols = [r[0] for r in res.fetchall()]
|
||||
print(f"INFO: Meglévő oszlopok: {cols}")
|
||||
|
||||
if "hashed_password" not in cols:
|
||||
print("❌ HIBA: A hashed_password oszlop még mindig hiányzik! A migráció nem volt sikeres.")
|
||||
return
|
||||
|
||||
# 2. Admin létrehozása
|
||||
res = await db.execute(text("SELECT id FROM data.users WHERE email = :e"), {"e": "admin@profibot.hu"})
|
||||
if res.fetchone():
|
||||
print("⚠ Az admin@profibot.hu már létezik.")
|
||||
else:
|
||||
admin = User(
|
||||
email="admin@profibot.hu",
|
||||
hashed_password=get_password_hash("Admin123!"),
|
||||
first_name="Admin",
|
||||
last_name="Profibot",
|
||||
role=UserRole.ADMIN,
|
||||
is_superuser=True,
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
print("✅ SIKER: Admin felhasználó létrehozva!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_fix())
|
||||
13
backend/app/init_db_direct.py
Executable file
13
backend/app/init_db_direct.py
Executable file
@@ -0,0 +1,13 @@
|
||||
import asyncio
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
from app.models import * # Minden modellt beimportálunk
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
# Ez a parancs hozza létre a táblákat a modellek alapján
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
print("✅ Minden tábla sikeresen létrejött a 'data' sémában!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(init_db())
|
||||
45
backend/app/main.py
Executable file
45
backend/app/main.py
Executable file
@@ -0,0 +1,45 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
import os
|
||||
from app.api.v1.api import api_router
|
||||
from app.api.v2.auth import router as auth_v2_router
|
||||
from app.models import Base
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
from app.db.session import engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("CREATE SCHEMA IF NOT EXISTS data"))
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
app = FastAPI(
|
||||
title="Traffic Ecosystem SuperApp 2.0",
|
||||
version="2.0.0",
|
||||
openapi_url="/api/v2/openapi.json",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://192.168.100.43:3000", # A szerver címe a böngészőben
|
||||
"http://localhost:3000", # Helyi teszteléshez
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ÚTVONALAK INTEGRÁCIÓJA
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
app.include_router(auth_v2_router, prefix="/api/v2/auth")
|
||||
|
||||
@app.get("/", tags=["health"])
|
||||
async def root():
|
||||
return {"status": "online", "version": "2.0.0", "docs": "/docs"}
|
||||
|
||||
24
backend/app/models/__init__.py
Executable file
24
backend/app/models/__init__.py
Executable file
@@ -0,0 +1,24 @@
|
||||
from app.db.base import Base
|
||||
from .user import User, UserRole
|
||||
from .company import Company, CompanyMember, VehicleAssignment
|
||||
from .vehicle import (
|
||||
Vehicle,
|
||||
VehicleOwnership,
|
||||
VehicleBrand,
|
||||
EngineSpec,
|
||||
ServiceProvider,
|
||||
ServiceRecord,
|
||||
VehicleCategory,
|
||||
VehicleModel,
|
||||
VehicleVariant
|
||||
)
|
||||
|
||||
# Alias a kompatibilitás kedvéért
|
||||
UserVehicle = Vehicle
|
||||
|
||||
__all__ = [
|
||||
"Base", "User", "UserRole", "Vehicle", "VehicleOwnership", "VehicleBrand",
|
||||
"EngineSpec", "ServiceProvider", "ServiceRecord", "Company",
|
||||
"CompanyMember", "VehicleAssignment", "UserVehicle", "VehicleCategory",
|
||||
"VehicleModel", "VehicleVariant"
|
||||
]
|
||||
BIN
backend/app/models/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
backend/app/models/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/models/__pycache__/company.cpython-312.pyc
Executable file
BIN
backend/app/models/__pycache__/company.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/models/__pycache__/user.cpython-312.pyc
Executable file
BIN
backend/app/models/__pycache__/user.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/models/__pycache__/vehicle.cpython-312.pyc
Executable file
BIN
backend/app/models/__pycache__/vehicle.cpython-312.pyc
Executable file
Binary file not shown.
63
backend/app/models/company.py
Executable file
63
backend/app/models/company.py
Executable file
@@ -0,0 +1,63 @@
|
||||
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])
|
||||
42
backend/app/models/core_logic.py
Executable file
42
backend/app/models/core_logic.py
Executable file
@@ -0,0 +1,42 @@
|
||||
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
|
||||
|
||||
class SubscriptionTier(Base):
|
||||
__tablename__ = "subscription_tiers"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String, unique=True) # Free, Premium, VIP, Custom
|
||||
rules = Column(JSON) # {"max_vehicles": 5, "allow_api": true}
|
||||
is_custom = Column(Boolean, default=False)
|
||||
|
||||
class OrganizationSubscription(Base):
|
||||
__tablename__ = "org_subscriptions"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id = Column(Integer, primary_key=True)
|
||||
org_id = Column(Integer, ForeignKey("data.organizations.id"))
|
||||
tier_id = Column(Integer, ForeignKey("data.subscription_tiers.id"))
|
||||
valid_from = Column(DateTime, server_default=func.now())
|
||||
valid_until = Column(DateTime)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
class CreditTransaction(Base):
|
||||
__tablename__ = "credit_logs"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id = Column(Integer, primary_key=True)
|
||||
org_id = Column(Integer, ForeignKey("data.organizations.id"))
|
||||
amount = Column(Numeric(10, 2))
|
||||
description = Column(String)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
class ServiceSpecialty(Base):
|
||||
"""Fa struktúra a szerviz szolgáltatásokhoz"""
|
||||
__tablename__ = "service_specialties"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id = Column(Integer, primary_key=True)
|
||||
parent_id = Column(Integer, ForeignKey("data.service_specialties.id"), nullable=True)
|
||||
name = Column(String, nullable=False)
|
||||
slug = Column(String, unique=True)
|
||||
|
||||
parent = relationship("ServiceSpecialty", remote_side=[id], backref="children")
|
||||
17
backend/app/models/email_log.py
Executable file
17
backend/app/models/email_log.py
Executable file
@@ -0,0 +1,17 @@
|
||||
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())
|
||||
21
backend/app/models/email_provider.py
Executable file
21
backend/app/models/email_provider.py
Executable file
@@ -0,0 +1,21 @@
|
||||
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
|
||||
30
backend/app/models/email_system.py
Executable file
30
backend/app/models/email_system.py
Executable file
@@ -0,0 +1,30 @@
|
||||
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)
|
||||
17
backend/app/models/email_template.py
Executable file
17
backend/app/models/email_template.py
Executable file
@@ -0,0 +1,17 @@
|
||||
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
|
||||
50
backend/app/models/expense.py
Executable file
50
backend/app/models/expense.py
Executable file
@@ -0,0 +1,50 @@
|
||||
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())
|
||||
70
backend/app/models/gamification.py
Executable file
70
backend/app/models/gamification.py
Executable file
@@ -0,0 +1,70 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import ForeignKey, String, Integer, DateTime, func, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.db.base import Base
|
||||
|
||||
# Közös beállítás az összes táblához ebben a fájlban
|
||||
SCHEMA_ARGS = {"schema": "data"}
|
||||
|
||||
class PointRule(Base):
|
||||
__tablename__ = "point_rules"
|
||||
__table_args__ = SCHEMA_ARGS
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
action_key: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
points: Mapped[int] = mapped_column(Integer, default=0)
|
||||
description: Mapped[Optional[str]] = mapped_column(String)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
class LevelConfig(Base):
|
||||
__tablename__ = "level_configs"
|
||||
__table_args__ = SCHEMA_ARGS
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
level_number: Mapped[int] = mapped_column(Integer, unique=True)
|
||||
min_points: Mapped[int] = mapped_column(Integer)
|
||||
rank_name: Mapped[str] = mapped_column(String)
|
||||
|
||||
class RegionalSetting(Base):
|
||||
__tablename__ = "regional_settings"
|
||||
__table_args__ = SCHEMA_ARGS
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
country_code: Mapped[str] = mapped_column(String, unique=True)
|
||||
currency: Mapped[str] = mapped_column(String, default="HUF")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
class PointsLedger(Base):
|
||||
__tablename__ = "points_ledger"
|
||||
__table_args__ = SCHEMA_ARGS
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
# JAVÍTVA: data.users.id hivatkozás
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.users.id"))
|
||||
points: Mapped[int] = mapped_column(Integer)
|
||||
reason: Mapped[str] = mapped_column(String)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
|
||||
class UserStats(Base):
|
||||
__tablename__ = "user_stats"
|
||||
__table_args__ = SCHEMA_ARGS
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
# JAVÍTVA: data.users.id hivatkozás
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.users.id"), unique=True)
|
||||
total_points: Mapped[int] = mapped_column(Integer, default=0)
|
||||
current_level: Mapped[int] = mapped_column(Integer, default=1)
|
||||
last_activity: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
|
||||
class Badge(Base):
|
||||
__tablename__ = "badges"
|
||||
__table_args__ = SCHEMA_ARGS
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String, unique=True)
|
||||
description: Mapped[str] = mapped_column(String)
|
||||
icon_url: Mapped[Optional[str]] = mapped_column(String)
|
||||
|
||||
class UserBadge(Base):
|
||||
__tablename__ = "user_badges"
|
||||
__table_args__ = SCHEMA_ARGS
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
# JAVÍTVA: data.users.id hivatkozás
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.users.id"))
|
||||
badge_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.badges.id"))
|
||||
earned_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
63
backend/app/models/history.py
Executable file
63
backend/app/models/history.py
Executable file
@@ -0,0 +1,63 @@
|
||||
# /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
|
||||
|
||||
# --- 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)
|
||||
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)
|
||||
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")
|
||||
|
||||
|
||||
# --- 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ú!
|
||||
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")
|
||||
29
backend/app/models/legal.py
Executable file
29
backend/app/models/legal.py
Executable file
@@ -0,0 +1,29 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
class LegalDocument(Base):
|
||||
__tablename__ = "legal_documents"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(255))
|
||||
content = Column(Text, nullable=False)
|
||||
version = Column(String(20), nullable=False)
|
||||
|
||||
region_code = Column(String(5), default="HU")
|
||||
language = Column(String(5), default="hu")
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class LegalAcceptance(Base):
|
||||
__tablename__ = "legal_acceptances"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("data.users.id"))
|
||||
document_id = Column(Integer, ForeignKey("data.legal_documents.id"))
|
||||
accepted_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
ip_address = Column(String(45))
|
||||
user_agent = Column(Text)
|
||||
25
backend/app/models/logistics.py
Executable file
25
backend/app/models/logistics.py
Executable file
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import Column, Integer, String, Enum
|
||||
from app.db.base import Base
|
||||
import enum
|
||||
|
||||
# Enum definiálása
|
||||
class LocationType(str, enum.Enum):
|
||||
stop = "stop" # Megálló / Parkoló
|
||||
warehouse = "warehouse" # Raktár
|
||||
client = "client" # Ügyfél címe
|
||||
|
||||
class Location(Base):
|
||||
__tablename__ = "locations"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
|
||||
# FONTOS: Itt is megadjuk a schema="data"-t, hogy ne a public sémába akarja írni!
|
||||
type = Column(Enum(LocationType, schema="data", name="location_type_enum"), nullable=False)
|
||||
|
||||
# Koordináták (egyelőre String, később PostGIS)
|
||||
coordinates = Column(String, nullable=True)
|
||||
address_full = Column(String, nullable=True)
|
||||
|
||||
capacity = Column(Integer, nullable=True)
|
||||
36
backend/app/models/organization.py
Executable file
36
backend/app/models/organization.py
Executable file
@@ -0,0 +1,36 @@
|
||||
import enum
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Enum, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
class OrgType(str, enum.Enum):
|
||||
INDIVIDUAL = "individual"
|
||||
SERVICE = "service"
|
||||
FLEET_OWNER = "fleet_owner"
|
||||
CLUB = "club"
|
||||
|
||||
class UITheme(str, enum.Enum):
|
||||
LIGHT = "light"
|
||||
DARK = "dark"
|
||||
SYSTEM = "system"
|
||||
|
||||
class Organization(Base):
|
||||
__tablename__ = "organizations"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
org_type = Column(Enum(OrgType), default=OrgType.INDIVIDUAL)
|
||||
|
||||
# Új UI beállítások a V2-höz
|
||||
theme = Column(Enum(UITheme), default=UITheme.SYSTEM)
|
||||
logo_url = Column(String, nullable=True)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Kapcsolatok
|
||||
# members = relationship("OrganizationMember", back_populates="organization")
|
||||
vehicles = relationship("UserVehicle", back_populates="current_org")
|
||||
26
backend/app/models/organization_member.py
Executable file
26
backend/app/models/organization_member.py
Executable file
@@ -0,0 +1,26 @@
|
||||
import enum
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Enum, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.base import Base
|
||||
|
||||
# Átnevezve OrgUserRole-ra, hogy ne ütközzön a globális UserRole-al
|
||||
class OrgUserRole(str, enum.Enum):
|
||||
OWNER = "OWNER"
|
||||
ADMIN = "ADMIN"
|
||||
FLEET_MANAGER = "FLEET_MANAGER"
|
||||
DRIVER = "DRIVER"
|
||||
|
||||
class OrganizationMember(Base):
|
||||
__tablename__ = "organization_members"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
org_id = Column(Integer, ForeignKey("data.organizations.id", ondelete="CASCADE"))
|
||||
user_id = Column(Integer, ForeignKey("data.users.id", ondelete="CASCADE"))
|
||||
# Itt is frissítjük a hivatkozást
|
||||
role = Column(Enum(OrgUserRole), default=OrgUserRole.DRIVER)
|
||||
|
||||
is_permanent = Column(Boolean, default=False)
|
||||
|
||||
organization = relationship("Organization", back_populates="members")
|
||||
# # # user = relationship("User", back_populates="memberships")
|
||||
71
backend/app/models/social.py
Executable file
71
backend/app/models/social.py
Executable file
@@ -0,0 +1,71 @@
|
||||
import enum
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, Enum, DateTime, Boolean, Text, UniqueConstraint
|
||||
from app.db.base import Base
|
||||
from datetime import datetime
|
||||
|
||||
# Enums (már schema="data" beállítással a biztonságért)
|
||||
class ModerationStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
rejected = "rejected"
|
||||
|
||||
class SourceType(str, enum.Enum):
|
||||
manual = "manual"
|
||||
ocr = "ocr"
|
||||
api_import = "import"
|
||||
|
||||
class ServiceProvider(Base):
|
||||
__tablename__ = "service_providers"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
address = Column(String, nullable=False)
|
||||
category = Column(String)
|
||||
|
||||
status = Column(Enum(ModerationStatus, schema="data", name="moderation_status_enum"), default=ModerationStatus.pending, nullable=False)
|
||||
source = Column(Enum(SourceType, schema="data", name="source_type_enum"), default=SourceType.manual, nullable=False)
|
||||
|
||||
# --- ÚJ MEZŐ ---
|
||||
validation_score = Column(Integer, default=0) # A közösségi szavazatok összege
|
||||
# ---------------
|
||||
|
||||
evidence_image_path = Column(String, nullable=True)
|
||||
added_by_user_id = Column(Integer, ForeignKey("data.users.id"))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class Vote(Base):
|
||||
__tablename__ = "votes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'provider_id', name='uq_user_provider_vote'),
|
||||
{"schema": "data"}
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False)
|
||||
provider_id = Column(Integer, ForeignKey("data.service_providers.id"), nullable=False)
|
||||
vote_value = Column(Integer, nullable=False) # +1 vagy -1
|
||||
|
||||
class Competition(Base):
|
||||
__tablename__ = "competitions"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String, nullable=False) # Pl: "Januári Feltöltő Verseny"
|
||||
description = Column(Text)
|
||||
start_date = Column(DateTime, nullable=False)
|
||||
end_date = Column(DateTime, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
class UserScore(Base):
|
||||
__tablename__ = "user_scores"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'competition_id', name='uq_user_competition_score'),
|
||||
{"schema": "data"}
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("data.users.id"))
|
||||
competition_id = Column(Integer, ForeignKey("data.competitions.id"))
|
||||
points = Column(Integer, default=0)
|
||||
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||
17
backend/app/models/staged_data.py
Executable file
17
backend/app/models/staged_data.py
Executable file
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import Column, Integer, String, JSON, DateTime, func
|
||||
from app.db.base import Base
|
||||
|
||||
class StagedVehicleData(Base):
|
||||
"""Ide érkeznek a nyers, validálatlan adatok a külső forrásokból"""
|
||||
__tablename__ = "staged_vehicle_data"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
source_url = Column(String) # Honnan jött az adat?
|
||||
raw_data = Column(JSON) # A teljes leszedett JSON struktúra
|
||||
|
||||
# Feldolgozási állapot
|
||||
status = Column(String, default="PENDING") # PENDING, PROCESSED, ERROR
|
||||
error_log = Column(String, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
15
backend/app/models/system_settings.py
Executable file
15
backend/app/models/system_settings.py
Executable file
@@ -0,0 +1,15 @@
|
||||
from sqlalchemy import Column, String, JSON
|
||||
from app.db.base import Base
|
||||
|
||||
class SystemSetting(Base):
|
||||
"""
|
||||
Globális rendszerbeállítások tárolása.
|
||||
Kulcs-Érték párok (JSON támogatással a komplex szabályokhoz).
|
||||
Példa: key='FREE_VEHICLE_LIMIT', value='2'
|
||||
"""
|
||||
__tablename__ = "system_settings"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
key = Column(String, primary_key=True, index=True)
|
||||
value = Column(JSON, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
15
backend/app/models/translation.py
Executable file
15
backend/app/models/translation.py
Executable file
@@ -0,0 +1,15 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, UniqueConstraint
|
||||
from app.db.base import Base
|
||||
|
||||
class Translation(Base):
|
||||
__tablename__ = "translations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("key", "lang_code", name="uq_translation_key_lang"),
|
||||
{"schema": "data"}
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(100), nullable=False, index=True)
|
||||
lang_code = Column(String(5), nullable=False, index=True)
|
||||
value = Column(Text, nullable=False)
|
||||
is_published = Column(Boolean, default=False) # Publikálási állapot
|
||||
35
backend/app/models/user.py
Executable file
35
backend/app/models/user.py
Executable file
@@ -0,0 +1,35 @@
|
||||
import enum
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
SERVICE = "service"
|
||||
FLEET_MANAGER = "fleet_manager"
|
||||
|
||||
class User(Base):
|
||||
__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=False)
|
||||
first_name = Column(String)
|
||||
last_name = Column(String)
|
||||
birthday = Column(Date, nullable=True)
|
||||
role = Column(String, default=UserRole.USER)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_superuser = Column(Boolean, default=False)
|
||||
is_company = Column(Boolean, default=False)
|
||||
company_name = Column(String, nullable=True)
|
||||
tax_number = Column(String, nullable=True)
|
||||
region_code = Column(String, default="HU")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Kapcsolatok
|
||||
# memberships = relationship("OrganizationMember", back_populates="user", cascade="all, delete-orphan")
|
||||
# vehicles = relationship("VehicleOwnership", back_populates="user", cascade="all, delete-orphan")
|
||||
77
backend/app/models/vehicle.py
Executable file
77
backend/app/models/vehicle.py
Executable file
@@ -0,0 +1,77 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, Numeric, DateTime, JSON, Date
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
from app.db.base import Base
|
||||
|
||||
class VehicleBrand(Base):
|
||||
__tablename__ = "vehicle_brands"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False, unique=True)
|
||||
slug = Column(String(100), unique=True)
|
||||
country_of_origin = Column(String(50))
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
class ServiceProvider(Base):
|
||||
__tablename__ = "service_providers"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
official_brand_partner = Column(Boolean, default=False)
|
||||
technical_rating_pct = Column(Integer, default=80)
|
||||
social_rating_pct = Column(Integer, default=80)
|
||||
location_city = Column(String(100))
|
||||
service_type = Column(String(50))
|
||||
search_tags = Column(String)
|
||||
latitude = Column(Numeric(10, 8))
|
||||
longitude = Column(Numeric(11, 8))
|
||||
is_active = Column(Boolean, default=True)
|
||||
records = relationship("ServiceRecord", back_populates="provider")
|
||||
|
||||
class EngineSpec(Base):
|
||||
__tablename__ = "engine_specs"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
engine_code = Column(String(50), unique=True)
|
||||
fuel_type = Column(String(20))
|
||||
power_kw = Column(Integer)
|
||||
default_service_interval_km = Column(Integer, default=15000)
|
||||
vehicles = relationship("Vehicle", back_populates="engine_spec")
|
||||
|
||||
class Vehicle(Base):
|
||||
__tablename__ = "vehicles"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
current_company_id = Column(Integer, ForeignKey("data.companies.id"))
|
||||
brand_id = Column(Integer, ForeignKey("data.vehicle_brands.id"))
|
||||
model_name = Column(String(100))
|
||||
engine_spec_id = Column(Integer, ForeignKey("data.engine_specs.id"))
|
||||
identification_number = Column(String(50), unique=True)
|
||||
license_plate = Column(String(20))
|
||||
tracking_mode = Column(String(10), default="km")
|
||||
current_rating_pct = Column(Integer, default=100)
|
||||
total_real_usage = Column(Numeric(15, 2), default=0)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
engine_spec = relationship("EngineSpec", back_populates="vehicles")
|
||||
service_records = relationship("ServiceRecord", back_populates="vehicle", cascade="all, delete-orphan")
|
||||
|
||||
# --- KOMPATIBILITÁSI RÉTEG A RÉGI KÓDOKHOZ ---
|
||||
VehicleOwnership = Vehicle
|
||||
VehicleModel = Vehicle
|
||||
VehicleVariant = Vehicle
|
||||
VehicleCategory = VehicleBrand # JAVÍTVA: Nagy "B" betűvel
|
||||
|
||||
class ServiceRecord(Base):
|
||||
__tablename__ = "service_records"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
vehicle_id = Column(UUID(as_uuid=True), ForeignKey("data.vehicles.id"))
|
||||
provider_id = Column(Integer, ForeignKey("data.service_providers.id"))
|
||||
service_date = Column(Date, nullable=False)
|
||||
usage_value = Column(Numeric(15, 2))
|
||||
repair_quality_pct = Column(Integer, default=100)
|
||||
|
||||
vehicle = relationship("Vehicle", back_populates="service_records")
|
||||
provider = relationship("ServiceProvider", back_populates="records") # JAVÍTVA
|
||||
54
backend/app/models/vehicle_catalog.py
Executable file
54
backend/app/models/vehicle_catalog.py
Executable file
@@ -0,0 +1,54 @@
|
||||
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'
|
||||
19
backend/app/models/vehicle_ownership.py
Executable file
19
backend/app/models/vehicle_ownership.py
Executable file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
class VehicleOwnership(Base):
|
||||
__tablename__ = "vehicle_ownerships"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vehicle_id = Column(Integer, ForeignKey("data.vehicles.id"))
|
||||
org_id = Column(Integer, ForeignKey("data.organizations.id"))
|
||||
|
||||
# Érvényességi időablak
|
||||
start_date = Column(DateTime(timezone=True), server_default=func.now())
|
||||
end_date = Column(DateTime(timezone=True), nullable=True) # Ha eladja, ide kerül a dátum
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Csak ezen az ablakon belüli szervizeket láthatja az aktuális tulajdonos
|
||||
21
backend/app/models/verification_token.py
Executable file
21
backend/app/models/verification_token.py
Executable file
@@ -0,0 +1,21 @@
|
||||
import enum
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
class TokenType(str, enum.Enum):
|
||||
email_verify = "email_verify"
|
||||
password_reset = "password_reset"
|
||||
|
||||
class VerificationToken(Base):
|
||||
__tablename__ = "verification_tokens"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("data.users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
token_hash = Column(String(64), unique=True, index=True, nullable=False)
|
||||
token_type = Column(Enum(TokenType, name="tokentype", schema="data"), nullable=False)
|
||||
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
47
backend/app/old_main.py
Executable file
47
backend/app/old_main.py
Executable file
@@ -0,0 +1,47 @@
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
from app.core.config import settings
|
||||
from app.core.security import decode_token
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.recommend import router as recommend_router
|
||||
|
||||
app = FastAPI(title="Service Finder API")
|
||||
|
||||
@app.middleware("http")
|
||||
async def jwt_claims_middleware(request: Request, call_next):
|
||||
"""
|
||||
Ha van Authorization: Bearer <token>, akkor claims bekerül request.state.claims-be.
|
||||
Auth endpointoknál nem kötelező.
|
||||
"""
|
||||
auth = request.headers.get("Authorization")
|
||||
if auth:
|
||||
scheme, token = get_authorization_scheme_param(auth)
|
||||
if scheme.lower() == "bearer" and token:
|
||||
try:
|
||||
claims = decode_token(token, settings.JWT_SECRET)
|
||||
if claims.get("type") != "access":
|
||||
raise HTTPException(status_code=401, detail="Invalid access token type")
|
||||
request.state.claims = claims
|
||||
except Exception:
|
||||
# nem dobunk mindig 401-et, csak a védett endpointoknál; itt “néma” marad
|
||||
request.state.claims = None
|
||||
else:
|
||||
request.state.claims = None
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/health/db")
|
||||
def health_db():
|
||||
from app.db.session import get_conn
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1;")
|
||||
return {"db": "ok", "result": cur.fetchone()[0]}
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(recommend_router)
|
||||
BIN
backend/app/schemas/__pycache__/user.cpython-312.pyc
Executable file
BIN
backend/app/schemas/__pycache__/user.cpython-312.pyc
Executable file
Binary file not shown.
40
backend/app/schemas/admin.py
Executable file
40
backend/app/schemas/admin.py
Executable file
@@ -0,0 +1,40 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime
|
||||
|
||||
# --- Pontszabályok (Point Rules) ---
|
||||
class PointRuleBase(BaseModel):
|
||||
rule_key: str
|
||||
points: int
|
||||
region_code: str = "GLOBAL"
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
is_active: bool = True
|
||||
|
||||
class PointRuleCreate(PointRuleBase):
|
||||
pass
|
||||
|
||||
class PointRuleResponse(PointRuleBase):
|
||||
id: int
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# --- Regionális Beállítások (MOT, Tax, stb.) ---
|
||||
class RegionalSettingBase(BaseModel):
|
||||
region_code: str
|
||||
setting_key: str
|
||||
value: Any # JSON adat (pl. {"months": 24})
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
class RegionalSettingCreate(RegionalSettingBase):
|
||||
pass
|
||||
|
||||
# --- Szintlépési Konfiguráció ---
|
||||
class LevelConfigBase(BaseModel):
|
||||
level_number: int
|
||||
min_points: int
|
||||
name_translation_key: str
|
||||
region_code: str = "GLOBAL"
|
||||
|
||||
class LevelConfigUpdate(LevelConfigBase):
|
||||
pass
|
||||
16
backend/app/schemas/auth.py
Executable file
16
backend/app/schemas/auth.py
Executable file
@@ -0,0 +1,16 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
class TokenData(BaseModel):
|
||||
email: Optional[str] = None
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8)
|
||||
full_name: str
|
||||
region_code: str = "HU"
|
||||
device_id: str # Az eszköz egyedi azonosítója a védelemhez
|
||||
56
backend/app/schemas/fleet.py
Executable file
56
backend/app/schemas/fleet.py
Executable file
@@ -0,0 +1,56 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import date, datetime
|
||||
from app.models.expense import ExpenseCategory
|
||||
|
||||
# --- Vehicle Schemas ---
|
||||
class VehicleBase(BaseModel):
|
||||
license_plate: str
|
||||
make: str
|
||||
model: str
|
||||
year: int
|
||||
fuel_type: Optional[str] = None
|
||||
vin: Optional[str] = None
|
||||
initial_odometer: int = 0
|
||||
mot_expiry_date: Optional[date] = None
|
||||
insurance_expiry_date: Optional[date] = None
|
||||
|
||||
class VehicleCreate(VehicleBase):
|
||||
pass
|
||||
|
||||
class VehicleResponse(VehicleBase):
|
||||
id: int
|
||||
current_odometer: int
|
||||
created_at: datetime
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# --- Event / Expense Schemas ---
|
||||
class EventBase(BaseModel):
|
||||
event_type: ExpenseCategory
|
||||
date: date
|
||||
odometer_value: int
|
||||
cost_amount: int
|
||||
description: Optional[str] = None
|
||||
is_diy: bool = False
|
||||
|
||||
# Ad-Hoc Provider mező: Ha stringet kapunk, a service megkeresi vagy létrehozza
|
||||
provider_name: Optional[str] = None
|
||||
provider_id: Optional[int] = None # Ha már ismert ID-t küldünk
|
||||
|
||||
class EventCreate(EventBase):
|
||||
pass
|
||||
|
||||
class EventResponse(EventBase):
|
||||
id: int
|
||||
vehicle_id: int
|
||||
odometer_anomaly: bool
|
||||
service_provider_id: Optional[int]
|
||||
image_paths: Optional[List[str]] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class TCOStats(BaseModel):
|
||||
vehicle_id: int
|
||||
total_cost: int
|
||||
breakdown: dict[str, int] # Kategóriánkénti bontás
|
||||
cost_per_km: Optional[float] = 0.0
|
||||
60
backend/app/schemas/social.py
Executable file
60
backend/app/schemas/social.py
Executable file
@@ -0,0 +1,60 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from app.models.social import ModerationStatus, SourceType
|
||||
|
||||
# --- Alap Sémák ---
|
||||
|
||||
class ServiceProviderBase(BaseModel):
|
||||
name: str
|
||||
address: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
source: SourceType = SourceType.manual
|
||||
|
||||
class ServiceProviderCreate(BaseModel):
|
||||
name: str
|
||||
address: str
|
||||
category: Optional[str] = None
|
||||
|
||||
class ServiceProviderResponse(ServiceProviderBase):
|
||||
id: int
|
||||
status: ModerationStatus
|
||||
validation_score: int # Látni kell a pontszámot
|
||||
evidence_image_path: Optional[str] = None
|
||||
added_by_user_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# --- Voting & Gamification Sémák ---
|
||||
|
||||
class VoteCreate(BaseModel):
|
||||
vote_value: int # Csak a +1 vagy -1 kell, a user_id jön a tokenből, a provider_id az URL-ből
|
||||
|
||||
class LeaderboardEntry(BaseModel):
|
||||
username: str
|
||||
points: int
|
||||
rank: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# --- GAMIFIKÁCIÓS SÉMÁK (Amiket a log keresett) ---
|
||||
|
||||
class BadgeSchema(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
image_url: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserStatSchema(BaseModel):
|
||||
user_id: int
|
||||
total_points: int
|
||||
current_level: int
|
||||
rank_title: str
|
||||
badges: List[BadgeSchema] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
10
backend/app/schemas/token.py
Executable file
10
backend/app/schemas/token.py
Executable file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
52
backend/app/schemas/user.py
Executable file
52
backend/app/schemas/user.py
Executable file
@@ -0,0 +1,52 @@
|
||||
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
|
||||
# Alap adatok, amik mindenhol kellenek
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
is_active: Optional[bool] = True
|
||||
is_superuser: bool = False
|
||||
region_code: str = "HU"
|
||||
|
||||
# --- REGISZTRÁCIÓ ---
|
||||
class UserRegister(UserBase):
|
||||
password: str
|
||||
birthday: Optional[date] = None
|
||||
is_company: bool = False
|
||||
company_name: Optional[str] = None
|
||||
tax_number: Optional[str] = None
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def block_temporary_emails(cls, v: str) -> str:
|
||||
blacklist = ['mailinator.com', '10minutemail.com', 'temp-mail.org', 'guerrillamail.com']
|
||||
domain = v.split('@')[-1].lower()
|
||||
if domain in blacklist:
|
||||
raise ValueError('Ideiglenes email szolgáltató nem engedélyezett!')
|
||||
return v
|
||||
|
||||
@field_validator('tax_number')
|
||||
@classmethod
|
||||
def validate_tax_id(cls, v: Optional[str], info) -> Optional[str]:
|
||||
if info.data.get('is_company') and (not v or len(v) < 8):
|
||||
raise ValueError('Cég esetén az adószám első 8 karaktere kötelező!')
|
||||
return v
|
||||
|
||||
# --- VÁLASZ (Ezt hiányolta a rendszer!) ---
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
is_company: bool
|
||||
company_name: Optional[str] = None
|
||||
|
||||
# Pydantic V2 konfiguráció az ORM (SQLAlchemy) támogatáshoz
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# Frissítéshez használt séma
|
||||
class UserUpdate(BaseModel):
|
||||
password: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
30
backend/app/schemas/vehicle.py
Executable file
30
backend/app/schemas/vehicle.py
Executable file
@@ -0,0 +1,30 @@
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Optional, List, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
class EngineSpecBase(BaseModel):
|
||||
engine_code: str
|
||||
fuel_type: str
|
||||
power_kw: int
|
||||
default_service_interval_km: int = 15000
|
||||
|
||||
class VehicleBase(BaseModel):
|
||||
brand_id: int
|
||||
model_name: str
|
||||
identification_number: str
|
||||
license_plate: Optional[str] = None
|
||||
tracking_mode: str = "km"
|
||||
|
||||
class VehicleCreate(VehicleBase):
|
||||
current_company_id: int
|
||||
engine_spec_id: int
|
||||
|
||||
class VehicleRead(VehicleBase):
|
||||
id: UUID
|
||||
current_rating_pct: int
|
||||
total_real_usage: float
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
38
backend/app/scripts/discovery_bot.py
Executable file
38
backend/app/scripts/discovery_bot.py
Executable file
@@ -0,0 +1,38 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from app.db.session import engine
|
||||
from datetime import datetime
|
||||
|
||||
async def log_discovery(conn, category, brand, model, action):
|
||||
await conn.execute(text("""
|
||||
INSERT INTO data.bot_discovery_logs (category, brand_name, model_name, action_taken)
|
||||
VALUES (:c, :b, :m, :a)
|
||||
"""), {"c": category, "b": brand, "m": model, "a": action})
|
||||
|
||||
async def run_discovery():
|
||||
async with engine.begin() as conn:
|
||||
print(f"🚀 Jármű felfedezés indul: {datetime.now()}")
|
||||
|
||||
# Jelenleg a CAR kategóriára fókuszálunk egy külső API segítségével (pl. NHTSA - Ingyenes)
|
||||
# Itt egy példa, hogyan bővül dinamikusan a rendszer
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Autók lekérése
|
||||
response = await client.get("https://vpic.nhtsa.dot.gov/api/vehicles/getallmakes?format=json")
|
||||
if response.status_code == 200:
|
||||
makes = response.json().get('Results', [])[:100] # Tesztként az első 100
|
||||
|
||||
for make in makes:
|
||||
brand_name = make['Make_Name'].strip()
|
||||
# Megnézzük, megvan-e már
|
||||
res = await conn.execute(text("SELECT id FROM data.vehicle_brands WHERE name = :n"), {"n": brand_name})
|
||||
if not res.scalar():
|
||||
await conn.execute(text("INSERT INTO data.vehicle_brands (category_id, name) VALUES (1, :n)"), {"n": brand_name})
|
||||
await log_discovery(conn, "CAR", brand_name, "ALL", "NEW_BRAND")
|
||||
print(f"✨ Új márka találva: {brand_name}")
|
||||
|
||||
await conn.commit()
|
||||
print("✅ Bot futása befejeződött.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_discovery())
|
||||
23
backend/app/seed_catalog.py
Executable file
23
backend/app/seed_catalog.py
Executable file
@@ -0,0 +1,23 @@
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.vehicle_catalog import VehicleCategory, VehicleMake
|
||||
|
||||
async def quick_seed():
|
||||
async with AsyncSessionLocal() as db:
|
||||
print("🌱 Alapkategóriák és márkák feltöltése...")
|
||||
|
||||
# 1. Kategóriák
|
||||
cats = [VehicleCategory(name_key="CAR"), VehicleCategory(name_key="MOTORCYCLE"), VehicleCategory(name_key="LCV")]
|
||||
db.add_all(cats)
|
||||
|
||||
# 2. Top Márkák (induláshoz)
|
||||
makes = ["Audi", "BMW", "Honda", "Skoda", "Volkswagen", "Toyota", "Ford", "Yamaha", "Suzuki"]
|
||||
for m_name in makes:
|
||||
db.add(VehicleMake(name=m_name))
|
||||
|
||||
await db.commit()
|
||||
print("✅ Kész! Most már van mihez modellt rendelni.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(quick_seed())
|
||||
118
backend/app/seed_data.py
Executable file
118
backend/app/seed_data.py
Executable file
@@ -0,0 +1,118 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Útvonal beállítása
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
# --- JAVÍTÁS 1: A Helyes Aszinkron Session Importálása ---
|
||||
from app.db.session import AsyncSessionLocal
|
||||
# ---------------------------------------------------------
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.social import ServiceProvider, Competition, ModerationStatus
|
||||
from app.services.social_service import vote_for_provider
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
async def run_simulation():
|
||||
# --- JAVÍTÁS 2: Itt is az AsyncSessionLocal-t használjuk ---
|
||||
async with AsyncSessionLocal() as db:
|
||||
# -----------------------------------------------------------
|
||||
print("--- 1. TAKARÍTÁS (Előző tesztadatok törlése) ---")
|
||||
# Kaszkádolt törlés a data sémában
|
||||
await db.execute(text("TRUNCATE TABLE data.user_scores, data.votes, data.service_providers, data.competitions, data.users RESTART IDENTITY CASCADE"))
|
||||
await db.commit()
|
||||
|
||||
print("\n--- 2. SZEREPLŐK LÉTREHOZÁSA ---")
|
||||
# Admin
|
||||
admin = User(email="admin@test.com", password_hash="hash", full_name="Admin", is_superuser=True)
|
||||
# Jófiú (aki valós boltokat tölt fel)
|
||||
good_user = User(email="good@test.com", password_hash="hash", full_name="Good Guy", reputation_score=5)
|
||||
# Rosszfiú (aki fake boltokat tölt fel)
|
||||
bad_user = User(email="bad@test.com", password_hash="hash", full_name="Spammer", reputation_score=-8) # Közel a banhoz
|
||||
# Szavazóközönség
|
||||
voter1 = User(email="voter1@test.com", password_hash="hash", full_name="Voter 1")
|
||||
voter2 = User(email="voter2@test.com", password_hash="hash", full_name="Voter 2")
|
||||
voter3 = User(email="voter3@test.com", password_hash="hash", full_name="Voter 3")
|
||||
voter4 = User(email="voter4@test.com", password_hash="hash", full_name="Voter 4")
|
||||
voter5 = User(email="voter5@test.com", password_hash="hash", full_name="Voter 5")
|
||||
|
||||
db.add_all([admin, good_user, bad_user, voter1, voter2, voter3, voter4, voter5])
|
||||
await db.commit()
|
||||
|
||||
# ID-k lekérése
|
||||
for u in [good_user, bad_user, voter1, voter2, voter3, voter4, voter5]:
|
||||
await db.refresh(u)
|
||||
|
||||
print("\n--- 3. VERSENY INDÍTÁSA ---")
|
||||
race = Competition(
|
||||
name="Nagy Januári Verseny",
|
||||
description="Töltsd fel a legtöbb boltot!",
|
||||
start_date=datetime.utcnow() - timedelta(days=1),
|
||||
end_date=datetime.utcnow() + timedelta(days=30),
|
||||
is_active=True
|
||||
)
|
||||
db.add(race)
|
||||
await db.commit()
|
||||
await db.refresh(race)
|
||||
|
||||
print("\n--- 4. SZCENÁRIÓ A: A JÓ FELHASZNÁLÓ ---")
|
||||
# Good Guy feltölt egy boltot
|
||||
good_shop = ServiceProvider(
|
||||
name="Korrekt Gumiszerviz",
|
||||
address="Fő utca 1.",
|
||||
added_by_user_id=good_user.id,
|
||||
status=ModerationStatus.pending
|
||||
)
|
||||
db.add(good_shop)
|
||||
await db.commit()
|
||||
await db.refresh(good_shop)
|
||||
|
||||
# A tömeg megszavazza (Kell 5 pont az elfogadáshoz)
|
||||
print(f"Szavazás a '{good_shop.name}' boltra...")
|
||||
await vote_for_provider(db, voter1.id, good_shop.id, 1)
|
||||
await vote_for_provider(db, voter2.id, good_shop.id, 1)
|
||||
await vote_for_provider(db, voter3.id, good_shop.id, 1)
|
||||
await vote_for_provider(db, voter4.id, good_shop.id, 1)
|
||||
await vote_for_provider(db, voter5.id, good_shop.id, 1) # Itt éri el az 5-öt!
|
||||
|
||||
# Eredmény ellenőrzése
|
||||
await db.refresh(good_user)
|
||||
print(f"Good Guy Hírneve (Elvárt: 6): {good_user.reputation_score}")
|
||||
|
||||
# Pontszám ellenőrzése
|
||||
points = await db.execute(text(f"SELECT points FROM data.user_scores WHERE user_id={good_user.id}"))
|
||||
scalar_points = points.scalar()
|
||||
print(f"Good Guy Verseny Pontjai (Elvárt: 10): {scalar_points}")
|
||||
|
||||
print("\n--- 5. SZCENÁRIÓ B: A ROSSZ FELHASZNÁLÓ (AUTO-BAN TESZT) ---")
|
||||
# Bad Guy feltölt egy fake boltot
|
||||
fake_shop = ServiceProvider(
|
||||
name="KAMU Bolt",
|
||||
address="Nincs ilyen utca",
|
||||
added_by_user_id=bad_user.id,
|
||||
status=ModerationStatus.pending
|
||||
)
|
||||
db.add(fake_shop)
|
||||
await db.commit()
|
||||
await db.refresh(fake_shop)
|
||||
|
||||
# A tömeg leszavazza (Kell -3 az elutasításhoz)
|
||||
print(f"Szavazás a '{fake_shop.name}' boltra...")
|
||||
await vote_for_provider(db, voter1.id, fake_shop.id, -1)
|
||||
await vote_for_provider(db, voter2.id, fake_shop.id, -1)
|
||||
await vote_for_provider(db, voter3.id, fake_shop.id, -1) # Itt éri el a -3-at!
|
||||
|
||||
# Eredmény ellenőrzése
|
||||
await db.refresh(bad_user)
|
||||
print(f"Bad User Hírneve (Elvárt: -10): {bad_user.reputation_score}")
|
||||
print(f"Bad User Aktív? (Elvárt: False/Banned): {bad_user.is_active}")
|
||||
|
||||
if not bad_user.is_active:
|
||||
print("✅ SIKER: A rendszer automatikusan kitiltotta a csalót!")
|
||||
else:
|
||||
print("❌ HIBA: A felhasználó még mindig aktív.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_simulation())
|
||||
46
backend/app/seed_honda.py
Executable file
46
backend/app/seed_honda.py
Executable file
@@ -0,0 +1,46 @@
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
async def seed():
|
||||
async with SessionLocal() as db:
|
||||
print("🚀 Honda adatok betöltése...")
|
||||
|
||||
# 1. Kategóriák (Autó, Motor) - Sima idézőjelekkel a SQL-ben
|
||||
await db.execute(text("""
|
||||
INSERT INTO data.vehicle_categories (name, slug)
|
||||
VALUES (\u0027Személyautó\u0027, \u0027car\u0027), (\u0027Motorkerékpár\u0027, \u0027motorcycle\u0027)
|
||||
ON CONFLICT (slug) DO NOTHING
|
||||
"""))
|
||||
|
||||
# 2. Márka: Honda
|
||||
res = await db.execute(text("""
|
||||
INSERT INTO data.vehicle_brands (name, slug, country_code)
|
||||
VALUES (\u0027Honda\u0027, \u0027honda\u0027, \u0027JP\u0027)
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id
|
||||
"""))
|
||||
brand_id = res.fetchone()[0]
|
||||
|
||||
# 3. Modellek listája
|
||||
models = [
|
||||
("Civic", "civic"),
|
||||
("Accord", "accord"),
|
||||
("CR-V", "cr-v"),
|
||||
("Jazz", "jazz"),
|
||||
("HR-V", "hr-v"),
|
||||
("NSX", "nsx")
|
||||
]
|
||||
|
||||
for name, slug in models:
|
||||
await db.execute(text(f"""
|
||||
INSERT INTO data.vehicle_models (brand_id, name, slug)
|
||||
VALUES ({brand_id}, \u0027{name}\u0027, \u0027{slug}\u0027)
|
||||
ON CONFLICT (brand_id, slug) DO NOTHING
|
||||
"""))
|
||||
|
||||
await db.commit()
|
||||
print("✅ Honda márka és modellek sikeresen betöltve!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
58
backend/app/seed_system.py
Executable file
58
backend/app/seed_system.py
Executable file
@@ -0,0 +1,58 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
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
|
||||
|
||||
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"
|
||||
)
|
||||
]
|
||||
|
||||
# 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. 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
|
||||
)
|
||||
|
||||
db.add_all(legal_docs)
|
||||
db.add(reg_template)
|
||||
db.add(sendgrid_provider)
|
||||
|
||||
await db.commit()
|
||||
print("🌱 Alapadatok sikeresen feltöltve!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_data())
|
||||
BIN
backend/app/services/__pycache__/config_service.cpython-312.pyc
Executable file
BIN
backend/app/services/__pycache__/config_service.cpython-312.pyc
Executable file
Binary file not shown.
BIN
backend/app/services/__pycache__/email_manager.cpython-312.pyc
Executable file
BIN
backend/app/services/__pycache__/email_manager.cpython-312.pyc
Executable file
Binary file not shown.
41
backend/app/services/config_service.py
Executable file
41
backend/app/services/config_service.py
Executable file
@@ -0,0 +1,41 @@
|
||||
from typing import Any, Optional
|
||||
from sqlalchemy import text
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
class ConfigService:
|
||||
@staticmethod
|
||||
async def get_setting(
|
||||
key: str,
|
||||
org_id: Optional[int] = None,
|
||||
region_code: Optional[str] = None,
|
||||
tier_id: Optional[int] = None,
|
||||
default: Any = None
|
||||
) -> Any:
|
||||
query = text("""
|
||||
SELECT value_json
|
||||
FROM data.system_settings
|
||||
WHERE key_name = :key
|
||||
AND (
|
||||
(org_id = :org_id) OR
|
||||
(org_id IS NULL AND tier_id = :tier_id) OR
|
||||
(org_id IS NULL AND tier_id IS NULL AND region_code = :region_code) OR
|
||||
(org_id IS NULL AND tier_id IS NULL AND region_code IS NULL)
|
||||
)
|
||||
ORDER BY
|
||||
(org_id IS NOT NULL) DESC,
|
||||
(tier_id IS NOT NULL) DESC,
|
||||
(region_code IS NOT NULL) DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
async with SessionLocal() as db:
|
||||
result = await db.execute(query, {
|
||||
"key": key,
|
||||
"org_id": org_id,
|
||||
"tier_id": tier_id,
|
||||
"region_code": region_code
|
||||
})
|
||||
row = result.fetchone()
|
||||
return row[0] if row else default
|
||||
|
||||
config = ConfigService()
|
||||
84
backend/app/services/email_manager.py
Executable file
84
backend/app/services/email_manager.py
Executable file
@@ -0,0 +1,84 @@
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
class EmailManager:
|
||||
@staticmethod
|
||||
def _render_template(template_key: str, variables: dict, lang: str = "hu") -> str:
|
||||
base_dir = "/app/app/templates/emails"
|
||||
file_path = f"{base_dir}/{lang}/{template_key}.html"
|
||||
if not os.path.exists(file_path):
|
||||
return ""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
body_html = f.read()
|
||||
for k, v in variables.items():
|
||||
body_html = body_html.replace(f"{{{{{k}}}}}", str(v))
|
||||
body_html = body_html.replace(f"{{{k}}}", str(v))
|
||||
return body_html
|
||||
|
||||
@staticmethod
|
||||
def _subject(template_key: str) -> str:
|
||||
subjects = {
|
||||
"registration": "Regisztráció - Service Finder",
|
||||
"password_reset": "Jelszó visszaállítás - Service Finder",
|
||||
"notification": "Értesítés - Service Finder",
|
||||
}
|
||||
return subjects.get(template_key, "Értesítés - Service Finder")
|
||||
|
||||
@staticmethod
|
||||
async def send_email(recipient: str, template_key: str, variables: dict, user_id: int = None, lang: str = "hu"):
|
||||
if settings.EMAIL_PROVIDER == "disabled":
|
||||
return {"status": "disabled"}
|
||||
|
||||
html = EmailManager._render_template(template_key, variables, lang=lang)
|
||||
subject = EmailManager._subject(template_key)
|
||||
|
||||
provider = settings.EMAIL_PROVIDER
|
||||
if provider == "auto":
|
||||
provider = "sendgrid" if settings.SENDGRID_API_KEY else "smtp"
|
||||
|
||||
# 1) SendGrid API (stabil)
|
||||
if provider == "sendgrid" and settings.SENDGRID_API_KEY:
|
||||
try:
|
||||
from sendgrid import SendGridAPIClient
|
||||
from sendgrid.helpers.mail import Mail
|
||||
|
||||
message = Mail(
|
||||
from_email=(settings.EMAILS_FROM_EMAIL, settings.EMAILS_FROM_NAME),
|
||||
to_emails=recipient,
|
||||
subject=subject,
|
||||
html_content=html or "<p>Üzenet</p>",
|
||||
)
|
||||
sg = SendGridAPIClient(settings.SENDGRID_API_KEY)
|
||||
sg.send(message)
|
||||
return {"status": "success", "provider": "sendgrid"}
|
||||
except Exception as e:
|
||||
# ha auto módban vagyunk, esünk vissza smtp-re
|
||||
if settings.EMAIL_PROVIDER != "auto":
|
||||
return {"status": "error", "provider": "sendgrid", "message": str(e)}
|
||||
|
||||
# 2) SMTP fallback
|
||||
if not settings.SMTP_HOST or not settings.SMTP_USER or not settings.SMTP_PASSWORD:
|
||||
return {"status": "error", "provider": "smtp", "message": "SMTP not configured"}
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = f"{settings.EMAILS_FROM_NAME} <{settings.EMAILS_FROM_EMAIL}>"
|
||||
msg["To"] = recipient
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(html or "Üzenet", "html"))
|
||||
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=15) as server:
|
||||
if settings.SMTP_USE_TLS:
|
||||
server.starttls()
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.send_message(msg)
|
||||
|
||||
return {"status": "success", "provider": "smtp"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "provider": "smtp", "message": str(e)}
|
||||
|
||||
email_manager = EmailManager()
|
||||
40
backend/app/services/fleet_service.py
Executable file
40
backend/app/services/fleet_service.py
Executable file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.models.vehicle import UserVehicle
|
||||
from app.models.expense import VehicleEvent
|
||||
from app.models.social import ServiceProvider, SourceType, ModerationStatus
|
||||
from app.schemas.fleet import EventCreate, TCOStats
|
||||
from app.services.gamification_service import GamificationService
|
||||
|
||||
async def add_vehicle_event(db: AsyncSession, vehicle_id: int, event_data: EventCreate, user_id: int):
|
||||
v_res = await db.execute(select(UserVehicle).where(UserVehicle.id == vehicle_id))
|
||||
vehicle = v_res.scalars().first()
|
||||
if not vehicle: return {"error": "Vehicle not found"}
|
||||
|
||||
final_provider_id = event_data.provider_id
|
||||
if event_data.is_diy: final_provider_id = None
|
||||
elif event_data.provider_name and not final_provider_id:
|
||||
p_res = await db.execute(select(ServiceProvider).where(func.lower(ServiceProvider.name) == event_data.provider_name.lower()))
|
||||
existing = p_res.scalars().first()
|
||||
if existing: final_provider_id = existing.id
|
||||
else:
|
||||
new_p = ServiceProvider(name=event_data.provider_name, added_by_user_id=user_id, status=ModerationStatus.pending)
|
||||
db.add(new_p); await db.flush(); final_provider_id = new_p.id
|
||||
await GamificationService.award_points(db, user_id, 50, f"Új helyszín: {event_data.provider_name}")
|
||||
|
||||
anomaly = event_data.odometer_value < vehicle.current_odometer
|
||||
new_event = VehicleEvent(vehicle_id=vehicle_id, service_provider_id=final_provider_id, odometer_anomaly=anomaly, **event_data.model_dump(exclude={"provider_id", "provider_name"}))
|
||||
db.add(new_event)
|
||||
if event_data.odometer_value > vehicle.current_odometer: vehicle.current_odometer = event_data.odometer_value
|
||||
await GamificationService.award_points(db, user_id, 20, f"Esemény: {event_data.event_type}")
|
||||
await db.commit(); await db.refresh(new_event)
|
||||
return new_event
|
||||
|
||||
async def calculate_tco(db: AsyncSession, vehicle_id: int) -> TCOStats:
|
||||
result = await db.execute(select(VehicleEvent.event_type, func.sum(VehicleEvent.cost_amount)).where(VehicleEvent.vehicle_id == vehicle_id).group_by(VehicleEvent.event_type))
|
||||
breakdown = {row[0]: row[1] for row in result.all()}
|
||||
v_res = await db.execute(select(UserVehicle).where(UserVehicle.id == vehicle_id))
|
||||
v = v_res.scalars().first()
|
||||
km = (v.current_odometer - v.initial_odometer) if v else 0
|
||||
cpk = sum(breakdown.values()) / km if km > 0 else 0
|
||||
return TCOStats(vehicle_id=vehicle_id, total_cost=sum(breakdown.values()), breakdown=breakdown, cost_per_km=round(cpk, 2))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user