Initial commit - Migrated to Dev environment

This commit is contained in:
2026-02-03 19:55:45 +00:00
commit a34e5b7976
3518 changed files with 481663 additions and 0 deletions

24
backend/Dockerfile Executable file
View File

@@ -0,0 +1,24 @@
FROM python:3.12-slim
WORKDIR /app
# 1. Rendszerfüggőségek telepítése
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 2. PIP frissítése (Ez némítja el a panaszkodást)
RUN pip install --upgrade pip
# 3. Függőségek telepítése
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 4. A kód másolása
COPY . .
ENV PYTHONPATH=/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,95 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
# Cím beállítása
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def build_db():
print(f"🔌 Kapcsolódás a rendszerhez...")
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("🏗️ 'DATA' séma előkészítése...")
await conn.execute(text("CREATE SCHEMA IF NOT EXISTS data;"))
# 1. FELHASZNÁLÓK TÁBLA (Céges/Magán logika)
print("👤 Users tábla létrehozása...")
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS data.users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) DEFAULT 'PRIVATE', -- 'PRIVATE', 'COMPANY', 'ADMIN'
company_name VARCHAR(255), -- Ha cég
tax_number VARCHAR(50), -- Ha cég
is_active BOOLEAN DEFAULT FALSE, -- Email megerősítésig
created_at TIMESTAMP DEFAULT NOW()
);
"""))
# 2. JÁRMŰ TÖRZS (A Fizikai Vas - Tulajdonos nélkül!)
print("🚗 Vehicles tábla létrehozása (A Vas)...")
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS data.vehicles (
id SERIAL PRIMARY KEY,
model_id INTEGER REFERENCES ref.vehicle_models(id),
vin VARCHAR(50) UNIQUE NOT NULL, -- ALVÁZSZÁM (Az igazi kulcs)
current_plate VARCHAR(20) NOT NULL, -- A mostani rendszám
production_year INTEGER,
created_at TIMESTAMP DEFAULT NOW()
);
"""))
# 3. ÉLETÚT ÉS JOGOSULTSÁG (A Történelem)
print("📜 History tábla létrehozása (Kié mikor volt?)...")
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS data.vehicle_history (
id SERIAL PRIMARY KEY,
vehicle_id INTEGER REFERENCES data.vehicles(id),
user_id INTEGER REFERENCES data.users(id),
role VARCHAR(20) NOT NULL, -- 'OWNER' (Tulaj), 'DRIVER' (Sofőr), 'LEASE' (Lízingelő)
start_date DATE NOT NULL, -- Mikor vette át?
end_date DATE, -- Mikor adta le? (Ha NULL, akkor nála van!)
start_mileage INTEGER, -- Óraállás átvételkor
end_mileage INTEGER, -- Óraállás leadáskor
is_active BOOLEAN GENERATED ALWAYS AS (end_date IS NULL) STORED -- Segédmező a gyors kereséshez
);
"""))
# 4. KÖLTSÉGEK (Szeparált pénzügyek)
print("💸 Costs tábla létrehozása...")
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS data.costs (
id SERIAL PRIMARY KEY,
vehicle_id INTEGER REFERENCES data.vehicles(id),
user_id INTEGER REFERENCES data.users(id), -- Ki fizette? (Csak ő láthatja!)
cost_type VARCHAR(50) NOT NULL, -- FUEL, SERVICE, TAX, INSURANCE, LEASE...
amount DECIMAL(10, 2) NOT NULL,
date DATE NOT NULL,
mileage_at_cost INTEGER,
description TEXT,
document_url VARCHAR(255), -- Fotó linkje
created_at TIMESTAMP DEFAULT NOW()
);
"""))
print("✅ KÉSZ! A Professzionális Adatmodell felépült.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(build_db())

View File

@@ -0,0 +1,51 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
# DB Config
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def check_data():
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("\n🚗 A TE GARÁZSOD (User ID: 1):")
print("-" * 80)
# Ez a lekérdezés összeköti a Történelmet (History), a Vasat (Vehicle) és a Katalógust (Model)
query = text("""
SELECT
vh.role,
vh.start_date,
v.vin,
v.current_plate,
m.name as brand,
mo.model_name
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
WHERE vh.user_id = 1;
""")
result = await conn.execute(query)
rows = result.fetchall()
if not rows:
print("📭 A garázs üres.")
else:
for r in rows:
print(f"🔹 {r.brand} {r.model_name} | 🆔 {r.vin} | 🔢 {r.current_plate}")
print(f" Jogviszony: {r.role} | Kezdete: {r.start_date}")
print("-" * 80)
await engine.dispose()
if __name__ == "__main__":
asyncio.run(check_data())

View File

@@ -0,0 +1,35 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
# Adatbázis elérés
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def create_user():
print(f"🔌 Kapcsolódás...")
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("👤 1-es számú felhasználó beszúrása...")
# Kényszerítjük az ID=1-et, hogy passzoljon a main.py-hoz
await conn.execute(text("""
INSERT INTO data.users (id, email, password_hash, role, is_active)
VALUES (1, 'demo@user.com', 'dummy_hash', 'PRIVATE', TRUE)
ON CONFLICT (id) DO NOTHING;
"""))
# Frissítjük a számlálót, hogy a következő user ID 2 legyen (ne akadjon össze)
await conn.execute(text("SELECT setval('data.users_id_seq', (SELECT MAX(id) FROM data.users));"))
print("✅ KÉSZ! A Demo User (ID: 1) létezik.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(create_user())

View File

@@ -0,0 +1,42 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def hire_driver():
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("👤 Kovács János (User ID: 2) létrehozása...")
# Létrehozzuk a User-t
await conn.execute(text("""
INSERT INTO data.users (id, email, password_hash, role, country, default_currency, is_active)
VALUES (2, 'sofor@ceg.hu', 'hash123', 'PRIVATE', 'HU', 'HUF', TRUE)
ON CONFLICT (id) DO NOTHING;
"""))
# Frissítjük a sorrendet
await conn.execute(text("SELECT setval('data.users_id_seq', (SELECT MAX(id) FROM data.users));"))
print("🤝 Hozzárendelés a Te cégedhez (ID: 1)...")
# Betesszük a fleet_members táblába
await conn.execute(text("""
INSERT INTO data.fleet_members (user_id, owner_id, role)
VALUES (2, 1, 'DRIVER')
ON CONFLICT (user_id, owner_id) DO NOTHING;
"""))
print("✅ KÉSZ! Kovács János mostantól a csapatod tagja.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(hire_driver())

View File

@@ -0,0 +1,17 @@
services:
service_finder_api:
build: .
container_name: service_finder_api
env_file:
- .env
networks:
- existing_net
ports:
- "8000:8000"
restart: unless-stopped
networks:
existing_net:
external: true
name: docker-server_internal_net

View File

@@ -0,0 +1,81 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
# Cím összeállítása (Ugyanaz a logika, mint a main.py-ban)
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def init_db():
print(f"🔌 Kapcsolódás ide: {DATABASE_URL}")
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("🏗️ Séma és Táblák létrehozása...")
# 1. Séma létrehozása
await conn.execute(text("CREATE SCHEMA IF NOT EXISTS ref;"))
# 2. Márka tábla
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS ref.vehicle_makes (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE
);
"""))
# 3. Modell tábla
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS ref.vehicle_models (
id SERIAL PRIMARY KEY,
make_id INTEGER REFERENCES ref.vehicle_makes(id),
model_name VARCHAR(100) NOT NULL,
category VARCHAR(50)
);
"""))
print("🧹 Meglévő adatok törlése (Tiszta lap)...")
await conn.execute(text("TRUNCATE TABLE ref.vehicle_models, ref.vehicle_makes RESTART IDENTITY CASCADE;"))
print("🚗 Adatok beszúrása...")
# Adatok
makes = ["BMW", "Audi", "Mercedes-Benz", "Toyota", "Honda", "Suzuki", "Volkswagen"]
models = [
("BMW", "3 Series", "Autó"), ("BMW", "5 Series", "Autó"), ("BMW", "X5", "Autó"), ("BMW", "R 1250 GS", "Motor"),
("Audi", "A3", "Autó"), ("Audi", "A4", "Autó"), ("Audi", "A6", "Autó"), ("Audi", "Q5", "Autó"),
("Mercedes-Benz", "C-Class", "Autó"), ("Mercedes-Benz", "E-Class", "Autó"), ("Mercedes-Benz", "S-Class", "Autó"), ("Mercedes-Benz", "G-Class", "Autó"),
("Toyota", "Corolla", "Autó"), ("Toyota", "Yaris", "Autó"), ("Toyota", "RAV4", "Autó"), ("Toyota", "Hilux", "Autó"),
("Honda", "Civic", "Autó"), ("Honda", "CR-V", "Autó"), ("Honda", "Africa Twin", "Motor"), ("Honda", "CB500X", "Motor"),
("Suzuki", "Swift", "Autó"), ("Suzuki", "Vitara", "Autó"), ("Suzuki", "S-Cross", "Autó"), ("Suzuki", "DL 650 V-Strom", "Motor"),
("Volkswagen", "Golf", "Autó"), ("Volkswagen", "Passat", "Autó"), ("Volkswagen", "Polo", "Autó"), ("Volkswagen", "Tiguan", "Autó")
]
# Márkák beszúrása
for make in makes:
await conn.execute(text(f"INSERT INTO ref.vehicle_makes (name) VALUES ('{make}') ON CONFLICT DO NOTHING;"))
# Modellek beszúrása (kicsit trükkös, mert kell a make_id)
for brand, model, cat in models:
# Megkeressük az ID-t
result = await conn.execute(text(f"SELECT id FROM ref.vehicle_makes WHERE name = '{brand}'"))
make_id = result.scalar()
# Beszúrjuk a modellt
await conn.execute(text(f"""
INSERT INTO ref.vehicle_models (make_id, model_name, category)
VALUES ({make_id}, '{model}', '{cat}')
"""))
print("✅ KÉSZ! Az adatbázis feltöltve.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(init_db())

View File

@@ -0,0 +1,61 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
# Cím beállítása
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def inspect_schema():
print(f"🔎 Kapcsolódás az adatbázishoz...")
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
# SQL lekérdezés a rendszer katalógusból (information_schema)
# Ez megmondja milyen táblák és oszlopok léteznek
query = text("""
SELECT
table_schema,
table_name,
column_name,
data_type,
is_nullable
FROM information_schema.columns
WHERE table_schema IN ('public', 'ref', 'data')
ORDER BY table_schema, table_name, ordinal_position;
""")
result = await conn.execute(query)
rows = result.fetchall()
if not rows:
print("⚠️ Nem találtam táblákat a 'ref' vagy 'data' sémákban!")
current_table = ""
for row in rows:
schema = row.table_schema
table = row.table_name
full_table_name = f"{schema}.{table}"
# Ha új táblához érünk, kiírjuk a nevét
if full_table_name != current_table:
print(f"\n📦 TÁBLA: {full_table_name.upper()}")
print("-" * 50)
print(f"{'OSZLOP NÉV':<20} | {'TÍPUS':<15} | {'KÖTELEZŐ?'}")
print("-" * 50)
current_table = full_table_name
# Oszlop adatok
req = "IGEN" if row.is_nullable == 'NO' else "nem"
print(f"{row.column_name:<20} | {row.data_type:<15} | {req}")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(inspect_schema())

View File

@@ -0,0 +1,69 @@
import asyncio
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import create_async_engine
# CSERÉLD KI a saját adataidra, ha nem ezeket használod!
DATABASE_URL = "postgresql+asyncpg://kincses:MiskociA74@postgres-db:5432/service_finder"
async def run_inspection():
# Aszinkron motor létrehozása
engine = create_async_engine(DATABASE_URL)
async with engine.connect() as conn:
def do_inspect(sync_conn):
inspector = inspect(sync_conn)
# 1. Sémák lekérése (amiket mi hoztunk létre + public)
target_schemas = ['public', 'data', 'ref']
all_schemas = inspector.get_schema_names()
schemas_to_check = [s for s in target_schemas if s in all_schemas]
print(f"{'='*60}")
print(f" JÁRMŰNYILVÁNTARTÓ RENDSZER - ADATBÁZIS AUDIT")
print(f"{'='*60}")
for schema in schemas_to_check:
print(f"\n--- SÉMA: {schema.upper()} ---")
tables = inspector.get_table_names(schema=schema)
if not tables:
print(" (Nincsenek táblák ebben a sémában)")
continue
for table_name in tables:
print(f"\n[Tábla: {schema}.{table_name}]")
# Oszlopok részletei
columns = inspector.get_columns(table_name, schema=schema)
for col in columns:
pk = " [PK]" if col['primary_key'] else ""
nullable = "NULL" if col['nullable'] else "NOT NULL"
print(f" L {col['name']:<15} | {str(col['type']):<12} | {nullable}{pk}")
# Idegen kulcsok (Kapcsolatok)
fks = inspector.get_foreign_keys(table_name, schema=schema)
for fk in fks:
constrained = fk['constrained_columns']
referred_schema = fk['referred_schema']
referred_table = fk['referred_table']
referred_cols = fk['referred_columns']
print(f" --> Kapcsolat: {constrained} -> {referred_schema}.{referred_table}({referred_cols})")
# Mivel az SQLAlchemy inspector alapvetően szinkron, run_sync-et használunk
await conn.run_sync(do_inspect)
# 2. Extra: Kiterjesztések ellenőrzése
print(f"\n{'='*60}")
print(" AKTÍV POSTGRESQL KITERJESZTÉSEK:")
result = await conn.execute(text("SELECT extname FROM pg_extension;"))
for row in result:
print(f" - {row[0]}")
print(f"{'='*60}")
await engine.dispose()
if __name__ == "__main__":
try:
asyncio.run(run_inspection())
except Exception as e:
print(f"HIBA TÖRTÉNT: {e}")

81
backend/_legacy_backup/main.py Executable file
View File

@@ -0,0 +1,81 @@
from fastapi import FastAPI, HTTPException, Form, Depends
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from datetime import datetime, timedelta, date
from jose import jwt
import bcrypt, os, traceback
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = "SZUPER_TITKOS_KULCS_2026"
ALGORITHM = "HS256"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
DATABASE_URL = os.getenv("DATABASE_URL", "").replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI()
# --- AUTH ---
def get_password_hash(p): return bcrypt.hashpw(p.encode('utf-8')[:72], bcrypt.gensalt()).decode('utf-8')
def verify_password(p, h): return bcrypt.checkpw(p.encode('utf-8')[:72], h.encode('utf-8'))
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
p = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return int(p.get("sub"))
except: raise HTTPException(status_code=401)
@app.post("/api/auth/register")
async def register(email: str = Form(...), password: str = Form(...)):
try:
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.users (email, password_hash) VALUES (:e, :p)"),
{"e": email, "p": get_password_hash(password)})
return {"status": "success"}
except Exception as e:
return JSONResponse(status_code=400, content={"detail": "Email már létezik vagy adatbázis hiba."})
@app.post("/api/auth/login")
async def login(f: OAuth2PasswordRequestForm = Depends()):
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT id, password_hash FROM data.users WHERE email = :e"), {"e": f.username})
u = res.fetchone()
if not u or not verify_password(f.password, u.password_hash): raise HTTPException(status_code=401, detail="Hibás adatok")
t = jwt.encode({"sub": str(u.id), "exp": datetime.utcnow() + timedelta(days=1)}, SECRET_KEY, algorithm=ALGORITHM)
return {"access_token": t, "token_type": "bearer"}
# --- DATA ---
@app.get("/api/meta/vehicle-hierarchy")
async def get_hierarchy():
async with AsyncSessionLocal() as session:
q = text("SELECT vm.category, m.name as brand, vm.id as model_id, vm.model_name FROM ref.vehicle_models vm JOIN ref.vehicle_makes m ON vm.make_id = m.id ORDER BY vm.category, m.name")
res = await session.execute(q)
h = {}
for r in res:
if r.category not in h: h[r.category] = {}
if r.brand not in h[r.category]: h[r.category][r.brand] = []
h[r.category][r.brand].append({"id": r.model_id, "name": r.model_name})
return h
@app.get("/api/meta/cost-types")
async def get_cost_types():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT code, name FROM ref.cost_types ORDER BY name"))
return {r.code: r.name for r in res.fetchall()}
@app.get("/api/my_vehicles")
async def my_vehicles(uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
q = text("SELECT v.id as vehicle_id, v.current_plate as plate, m.name as brand, mo.model_name as model, v.status FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id WHERE vh.user_id = :uid AND vh.end_date IS NULL")
res = await session.execute(q, {"uid": uid})
return [dict(r._mapping) for r in res.fetchall()]
@app.get("/")
async def index(): return FileResponse("/app/frontend/index.html")

120
backend/_legacy_backup/main_2.py Executable file
View File

@@ -0,0 +1,120 @@
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel, validator
from typing import Optional
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
from datetime import date
from dotenv import load_dotenv
load_dotenv()
# --- 1. ADATBÁZIS KAPCSOLAT ---
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- 2. ADATMODELLEK (VALIDÁCIÓVAL) ---
class VehicleRegister(BaseModel):
model_id: int
vin: str # Alvázszám (KÖTELEZŐ!)
plate: str # Rendszám
mileage: int # Km óra
purchase_date: date
role: str = "OWNER" # Alapértelmezett: Tulajdonos
# Adatőr: Automatikus nagybetűsítés és tisztítás
@validator('vin')
def clean_vin(cls, v):
if len(v) < 5: raise ValueError('Az alvázszám túl rövid!')
return v.upper().replace("-", "").replace(" ", "")
@validator('plate')
def clean_plate(cls, v):
return v.upper().replace("-", "").replace(" ", "")
@validator('mileage')
def positive_mileage(cls, v):
if v < 0: raise ValueError('A kilométer nem lehet negatív!')
return v
# --- 3. VÉGPONTOK ---
# Lista lekérése (Katalógus)
@app.get("/api/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
result = await session.execute(text("""
SELECT vm.id, m.name as brand, vm.model_name, vm.category
FROM ref.vehicle_models vm
JOIN ref.vehicle_makes m ON vm.make_id = m.id
ORDER BY m.name, vm.model_name
"""))
return [{"id": r.id, "brand": r.brand, "model": r.model_name, "category": r.category} for r in result.fetchall()]
# ÚJ: JÁRMŰ REGISZTRÁCIÓ (A Nagy Logika)
@app.post("/api/register")
async def register_vehicle(data: VehicleRegister):
async with AsyncSessionLocal() as session:
async with session.begin(): # Tranzakció indítása
try:
# 1. Megnézzük, létezik-e már ez a VAS (Alvázszám alapján)?
check_query = text("SELECT id FROM data.vehicles WHERE vin = :vin")
result = await session.execute(check_query, {"vin": data.vin})
existing_car_id = result.scalar()
vehicle_id = existing_car_id
# 2. HA NEM LÉTEZIK -> Létrehozzuk a VASAT
if not vehicle_id:
print(f"🆕 Új autó az adatbázisban: {data.vin}")
insert_car = text("""
INSERT INTO data.vehicles (model_id, vin, current_plate)
VALUES (:mid, :vin, :plt)
RETURNING id
""")
res = await session.execute(insert_car, {
"mid": data.model_id, "vin": data.vin, "plt": data.plate
})
vehicle_id = res.scalar()
else:
print(f"♻️ Létező autó átvétele: {data.vin} (ID: {vehicle_id})")
# Itt később lezárhatjuk az előző tulajdonos history-ját!
# 3. BEJEGYZÉS A TÖRTÉNELEMBE (HISTORY)
# Ez köti össze a User-t (most fixen ID=1) az Autóval
insert_history = text("""
INSERT INTO data.vehicle_history
(vehicle_id, user_id, role, start_date, start_mileage)
VALUES (:vid, 1, :role, :s_date, :s_mil)
""")
await session.execute(insert_history, {
"vid": vehicle_id,
"role": data.role,
"s_date": data.purchase_date,
"s_mil": data.mileage
})
return {"status": "success", "message": "Jármű sikeresen rögzítve a flottában!"}
except Exception as e:
print(f"❌ Hiba: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Frontend kiszolgálása
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"):
return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1,49 @@
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
from dotenv import load_dotenv
load_dotenv()
app = FastAPI(title="Service Finder API")
# --- A DUPLA JAVÍTÁS ---
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
# Vészhelyzeti fallback (ha nem lenne .env)
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
# 1. Javítás: Driver csere (asyncpg)
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://")
# 2. Javítás: Adatbázis név csere (Ha véletlenül _db a vége, levágjuk)
fixed_url = fixed_url.replace("/service_finder_db", "/service_finder")
print(f"🔧 VÉGLEGES ADATBÁZIS CÍM: {fixed_url}")
# Kapcsolódás a javított címmel
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@app.get("/")
async def root():
return {"message": "Service Finder API működik! 🚀"}
@app.get("/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
try:
query = text("""
SELECT m.name as brand, vm.model_name, vm.category
FROM ref.vehicle_models vm
JOIN ref.vehicle_makes m ON vm.make_id = m.id
ORDER BY m.name, vm.model_name
""")
result = await session.execute(query)
vehicles = result.fetchall()
return [{"brand": r.brand, "model": r.model_name, "category": r.category} for r in vehicles]
except Exception as e:
return {"error": str(e)}

View File

@@ -0,0 +1,52 @@
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
from dotenv import load_dotenv
# Betöltjük a környezeti változókat
load_dotenv()
app = FastAPI(title="Service Finder API")
# --- A FIX ---
# Kiolvassuk a címet
raw_url = os.getenv("DATABASE_URL")
# Ha nincs beállítva, adunk egy vészhelyzeti alapértelmezettet
if not raw_url:
print("⚠️ FIGYELEM: Nincs DATABASE_URL, alapértelmezett használata!")
# Itt a konténer belső nevét (postgres-db) használjuk
raw_url = "postgresql://admin:MiskociA74@postgres-db:5432/service_finder"
# A LÉNYEG: Ha hiányzik az '+asyncpg', hozzáadjuk!
if "asyncpg" not in raw_url:
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://")
print(f"🔧 URL javítva erre: {DATABASE_URL}")
else:
DATABASE_URL = raw_url
# Motor indítása a javított URL-lel
engine = create_async_engine(DATABASE_URL)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@app.get("/")
async def root():
return {"message": "Service Finder API működik! 🚀"}
@app.get("/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
try:
query = text("""
SELECT m.name as brand, vm.model_name, vm.category
FROM ref.vehicle_models vm
JOIN ref.vehicle_makes m ON vm.make_id = m.id
ORDER BY m.name, vm.model_name
""")
result = await session.execute(query)
vehicles = result.fetchall()
return [{"brand": r.brand, "model": r.model_name, "category": r.category} for r in vehicles]
except Exception as e:
return {"error": str(e)}

View File

@@ -0,0 +1,71 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def seed_cost_types():
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("🔨 Költség típusok tábla létrehozása...")
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS ref.cost_types (
code VARCHAR(50) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_code VARCHAR(50) REFERENCES ref.cost_types(code), -- Hierarchia!
is_active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0
);
"""))
print("📥 Adatok feltöltése...")
# Először a szülők (Főkategóriák)
parents = [
('FUEL', '⛽ Tankolás', 10),
('PURCHASE', '💰 Beszerzés / Pénzügy', 20),
('INSURANCE', '📄 Biztosítás', 30),
('TAX', '🏛️ Adó / Illeték', 40),
('SERVICE', '🔧 Szerviz', 50),
('OTHER', 'Egyéb', 99)
]
for p in parents:
await conn.execute(text("INSERT INTO ref.cost_types (code, name, sort_order) VALUES (:c, :n, :s) ON CONFLICT (code) DO NOTHING"), {"c": p[0], "n": p[1], "s": p[2]})
# Aztán a gyerekek (Alkategóriák)
children = [
# Beszerzés
('PURCHASE_PRICE', 'Vételár', 'PURCHASE'),
('FINANCE_LEASE', 'Lízing díj', 'PURCHASE'),
('FINANCE_LOAN', 'Hitel törlesztő', 'PURCHASE'),
('IMPORT_FEE', 'Honosítás / Regadó', 'PURCHASE'),
# Biztosítás
('INSURANCE_KGFB', 'Kötelező (KGFB)', 'INSURANCE'),
('INSURANCE_CASCO', 'Casco', 'INSURANCE'),
('INSURANCE_GAP', 'GAP', 'INSURANCE'),
('INSURANCE_ASSIST', 'Assistance', 'INSURANCE'),
# Adó
('TAX_WEIGHT', 'Gépjárműadó (Súlyadó)', 'TAX'),
('TAX_COMPANY', 'Cégautóadó', 'TAX'),
('TAX_TRANSFER', 'Vagyonszerzési illeték', 'TAX'),
('TAX_OTHER', 'Egyéb adó', 'TAX'),
# Szerviz
('SERVICE_MAINTENANCE', 'Kötelező karbantartás', 'SERVICE'),
('SERVICE_REPAIR', 'Javítás', 'SERVICE'),
('SERVICE_TIRE', 'Gumicsere', 'SERVICE'),
('SERVICE_MOT', 'Műszaki vizsga', 'SERVICE')
]
for c in children:
await conn.execute(text("INSERT INTO ref.cost_types (code, name, parent_code) VALUES (:c, :n, :p) ON CONFLICT (code) DO NOTHING"), {"c": c[0], "n": c[1], "p": c[2]})
print("✅ KÉSZ! A tudás most már az adatbázisban van, nem a HTML-ben.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed_cost_types())

View File

View File

@@ -0,0 +1,46 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def build_audit_system():
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("📜 Audit Log tábla létrehozása...")
# Ez a tábla sosem töröl, csak ír! (Append-only)
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS data.audit_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES data.users(id), -- KI csinálta?
event_type VARCHAR(50) NOT NULL, -- MIT? (pl. ISSUE_REPORT, MILEAGE_UPDATE)
target_id INTEGER, -- MELYIK autón? (Vehicle ID)
old_value TEXT, -- MI volt előtte? (A visszaállításhoz)
new_value TEXT, -- MI lett utána?
details TEXT, -- Egyéb megjegyzés (pl. hiba leírása)
ip_address VARCHAR(45), -- Honnan? (Biztonság)
created_at TIMESTAMP DEFAULT NOW()
);
"""))
print("🚦 Jármű Státusz mezők hozzáadása...")
# Bővítjük a járműveket, hogy tárolják a hibát
await conn.execute(text("""
ALTER TABLE data.vehicles
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'OK', -- OK, WARNING, CRITICAL
ADD COLUMN IF NOT EXISTS current_issue TEXT; -- A hiba leírása
"""))
print("✅ KÉSZ! A mindent látó szem (Audit Log) aktív.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(build_audit_system())

View File

@@ -0,0 +1,56 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def expand_categories():
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("📥 Új Főkategóriák beszúrása...")
parents = [
('EQUIPMENT', '🛠️ Felszerelés / Extrák', 25), # Vételár után
('OPERATIONAL', '🅿️ Üzemeltetés / Út', 15), # Tankolás után
('FINE', '👮 Bírság / Büntetés', 90)
]
for p in parents:
await conn.execute(text("INSERT INTO ref.cost_types (code, name, sort_order) VALUES (:c, :n, :s) ON CONFLICT (code) DO NOTHING"), {"c": p[0], "n": p[1], "s": p[2]})
print("📥 Új Alkategóriák beszúrása...")
children = [
# Felszerelés (EQUIPMENT)
('EQUIP_SHELVING', 'Raktérburkolat / Polcrendszer', 'EQUIPMENT'),
('EQUIP_BRANDING', 'Matricázás / Fóliázás', 'EQUIPMENT'),
('EQUIP_TOWBAR', 'Vonóhorog / Tetőcsomagtartó', 'EQUIPMENT'),
('EQUIP_SAFETY', 'Kötelező tartozékok (Eü. csomag)', 'EQUIPMENT'),
('EQUIP_GPS', 'GPS / Nyomkövető hardver', 'EQUIPMENT'),
('EQUIP_WINTER_TIRE', 'Téli gumi szett (Beszerzés)', 'EQUIPMENT'),
# Üzemeltetés (OPERATIONAL)
('OP_PARKING', 'Parkolás', 'OPERATIONAL'),
('OP_TOLL', 'Útdíj / Matrica / Behajtás', 'OPERATIONAL'),
('OP_WASH', 'Mosás / Kozmetika', 'OPERATIONAL'),
('OP_ADBLUE', 'AdBlue', 'OPERATIONAL'),
('OP_EV_CHARGE', 'Elektromos töltés', 'OPERATIONAL'),
('OP_STORAGE', 'Gumi hotel / Tárolás', 'OPERATIONAL'),
# Bírság (FINE)
('FINE_SPEEDING', 'Gyorshajtás', 'FINE'),
('FINE_PARKING', 'Parkolási bírság', 'FINE'),
('FINE_ADMIN', 'Közigazgatási bírság', 'FINE')
]
for c in children:
await conn.execute(text("INSERT INTO ref.cost_types (code, name, parent_code) VALUES (:c, :n, :p) ON CONFLICT (code) DO NOTHING"), {"c": c[0], "n": c[1], "p": c[2]})
print("✅ KÉSZ! A költséglista most már teljeskörű.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(expand_categories())

View File

@@ -0,0 +1,46 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def upgrade_db():
print(f"🔌 Kapcsolódás...")
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("🌍 Felhasználói tábla bővítése (Ország + Alapértelmezett pénznem)...")
# 1. Users tábla bővítése
await conn.execute(text("""
ALTER TABLE data.users
ADD COLUMN IF NOT EXISTS country VARCHAR(2) DEFAULT 'HU',
ADD COLUMN IF NOT EXISTS default_currency VARCHAR(3) DEFAULT 'HUF';
"""))
print("💶 Költség tábla bővítése (Tranzakciós pénznem)...")
# 2. Costs tábla bővítése
await conn.execute(text("""
ALTER TABLE data.costs
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'HUF';
"""))
# Frissítjük a meglévő Demo Usert (ID=1)
print("👤 Demo User beállítása: Magyarország / HUF")
await conn.execute(text("""
UPDATE data.users
SET country = 'HU', default_currency = 'HUF'
WHERE id = 1;
"""))
print("✅ KÉSZ! Az adatbázis mostantól támogatja a több pénznemet.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(upgrade_db())

View File

@@ -0,0 +1,26 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def add_doc_column():
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("📄 Dokumentum oszlop hozzáadása a Costs táblához...")
await conn.execute(text("""
ALTER TABLE data.costs
ADD COLUMN IF NOT EXISTS document_url VARCHAR(255);
"""))
print("✅ KÉSZ! Mehetnek a fájlok.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(add_doc_column())

View File

@@ -0,0 +1,58 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def upgrade_invites():
print(f"🔌 Kapcsolódás...")
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("📨 Invitations (Meghívók) tábla létrehozása...")
# Ez tárolja a függőben lévő meghívásokat
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS data.invitations (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
inviter_id INTEGER REFERENCES data.users(id), -- Ki hívta meg? (Cég)
role VARCHAR(20) NOT NULL, -- Milyen szerepre? (MANAGER, DRIVER)
access_level VARCHAR(20) DEFAULT 'FULL', -- A/B Sofőr szint
token VARCHAR(100) UNIQUE NOT NULL, -- A titkos link kódja
status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, ACCEPTED, EXPIRED
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP
);
"""))
print("🤝 Fleet Members (Többcéges tagság) tábla létrehozása...")
# Ez teszi lehetővé, hogy valaki több céghez is tartozzon
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS data.fleet_members (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES data.users(id), -- A Dolgozó
owner_id INTEGER REFERENCES data.users(id), -- A Cég / Tulajdonos
role VARCHAR(20) NOT NULL, -- FLEET_MANAGER, DRIVER
joined_at TIMESTAMP DEFAULT NOW(),
-- Egy ember egy cégnél csak egyszer szerepelhet
UNIQUE(user_id, owner_id)
);
"""))
# Takarítás: A régi 'parent_id' már nem kell, mert a fleet_members kiváltja
# De biztonságból egyelőre csak NULL-ra állítjuk, nem töröljük az oszlopot
# await conn.execute(text("UPDATE data.users SET parent_id = NULL;"))
print("✅ KÉSZ! A rendszer készen áll a biztonságos meghívókra.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(upgrade_invites())

View File

@@ -0,0 +1,61 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def upgrade_permissions():
print(f"🔌 Kapcsolódás...")
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("👑 USERS tábla bővítése (Rendszer és Szervezeti szintek)...")
# 1. SYS_ROLE: Rendszergazda / Moderátor / User
await conn.execute(text("""
ALTER TABLE data.users
ADD COLUMN IF NOT EXISTS sys_role VARCHAR(20) DEFAULT 'USER';
"""))
# 2. HIERARCHIA: Ki a főnököd? (Parent User ID)
# Ha NULL, akkor ő a Cégtulajdonos (SuperUser). Ha van ID, akkor Alkalmazott.
await conn.execute(text("""
ALTER TABLE data.users
ADD COLUMN IF NOT EXISTS parent_id INTEGER REFERENCES data.users(id);
"""))
# 3. ORG_ROLE: Cégen belüli szerep (Owner, Fleet Manager, Employee)
await conn.execute(text("""
ALTER TABLE data.users
ADD COLUMN IF NOT EXISTS org_role VARCHAR(20) DEFAULT 'OWNER';
"""))
print("🚦 VEHICLE_HISTORY tábla bővítése (Sofőr jogosultságok)...")
# 4. ACCESS_LEVEL: Mit láthat a sofőr? (COST_MANAGER vs LOG_ONLY)
await conn.execute(text("""
ALTER TABLE data.vehicle_history
ADD COLUMN IF NOT EXISTS access_level VARCHAR(20) DEFAULT 'FULL';
-- Lehetséges értékek: 'FULL', 'COST_MANAGER', 'LOG_ONLY'
"""))
# --- DEMO ADATOK FRISSÍTÉSE ---
print("👤 Demo User (ID:1) kinevezése Rendszergazdának és Cégtulajdonosnak...")
await conn.execute(text("""
UPDATE data.users
SET sys_role = 'SYS_ADMIN', org_role = 'OWNER', parent_id = NULL
WHERE id = 1;
"""))
print("✅ KÉSZ! A jogosultsági mátrix beépítve az adatbázisba.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(upgrade_permissions())

149
backend/alembic.ini Executable file
View File

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

0
backend/app/__init__.py Executable file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

132
backend/app/api/auth.py Executable file
View 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
View 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
View 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

Binary file not shown.

12
backend/app/api/v1/api.py Executable file
View 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"])

View 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."}

View 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!"}

View 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}

View 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."}

View 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)}

View 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()

View 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)

View 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()]

View 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]
}

View 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)

View 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

View 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}

View 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
View 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"])

Binary file not shown.

262
backend/app/api/v2/auth.py Executable file
View 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
View 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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

53
backend/app/core/config.py Executable file
View 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
View 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
View 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
View 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
View File

11
backend/app/database.py Executable file
View 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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

10
backend/app/db/base.py Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

63
backend/app/models/company.py Executable file
View 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])

View 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
View 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())

View 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

View 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)

View 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
View 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())

View 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
View 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
View 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
View 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)

View 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")

View 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
View 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)

View 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())

View 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)

View 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
View 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
View 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

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