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

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

View File

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

View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Boolean
from sqlalchemy.sql import func
from app.db.base import Base
class VehicleOwnership(Base):
__tablename__ = "vehicle_ownerships"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
vehicle_id = Column(Integer, ForeignKey("data.vehicles.id"))
org_id = Column(Integer, ForeignKey("data.organizations.id"))
# Érvényességi időablak
start_date = Column(DateTime(timezone=True), server_default=func.now())
end_date = Column(DateTime(timezone=True), nullable=True) # Ha eladja, ide kerül a dátum
is_active = Column(Boolean, default=True)
# Csak ezen az ablakon belüli szervizeket láthatja az aktuális tulajdonos

View File

@@ -0,0 +1,21 @@
import enum
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum
from sqlalchemy.sql import func
from app.db.base import Base
class TokenType(str, enum.Enum):
email_verify = "email_verify"
password_reset = "password_reset"
class VerificationToken(Base):
__tablename__ = "verification_tokens"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("data.users.id", ondelete="CASCADE"), nullable=False)
token_hash = Column(String(64), unique=True, index=True, nullable=False)
token_type = Column(Enum(TokenType, name="tokentype", schema="data"), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)

47
backend/app/old_main.py Executable file
View File

@@ -0,0 +1,47 @@
from fastapi import FastAPI, Request, HTTPException
from fastapi.security.utils import get_authorization_scheme_param
from app.core.config import settings
from app.core.security import decode_token
from app.api.auth import router as auth_router
from app.api.recommend import router as recommend_router
app = FastAPI(title="Service Finder API")
@app.middleware("http")
async def jwt_claims_middleware(request: Request, call_next):
"""
Ha van Authorization: Bearer <token>, akkor claims bekerül request.state.claims-be.
Auth endpointoknál nem kötelező.
"""
auth = request.headers.get("Authorization")
if auth:
scheme, token = get_authorization_scheme_param(auth)
if scheme.lower() == "bearer" and token:
try:
claims = decode_token(token, settings.JWT_SECRET)
if claims.get("type") != "access":
raise HTTPException(status_code=401, detail="Invalid access token type")
request.state.claims = claims
except Exception:
# nem dobunk mindig 401-et, csak a védett endpointoknál; itt “néma” marad
request.state.claims = None
else:
request.state.claims = None
return await call_next(request)
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/health/db")
def health_db():
from app.db.session import get_conn
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1;")
return {"db": "ok", "result": cur.fetchone()[0]}
app.include_router(auth_router)
app.include_router(recommend_router)

Binary file not shown.

40
backend/app/schemas/admin.py Executable file
View File

@@ -0,0 +1,40 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, Any
from datetime import datetime
# --- Pontszabályok (Point Rules) ---
class PointRuleBase(BaseModel):
rule_key: str
points: int
region_code: str = "GLOBAL"
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
is_active: bool = True
class PointRuleCreate(PointRuleBase):
pass
class PointRuleResponse(PointRuleBase):
id: int
model_config = ConfigDict(from_attributes=True)
# --- Regionális Beállítások (MOT, Tax, stb.) ---
class RegionalSettingBase(BaseModel):
region_code: str
setting_key: str
value: Any # JSON adat (pl. {"months": 24})
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
class RegionalSettingCreate(RegionalSettingBase):
pass
# --- Szintlépési Konfiguráció ---
class LevelConfigBase(BaseModel):
level_number: int
min_points: int
name_translation_key: str
region_code: str = "GLOBAL"
class LevelConfigUpdate(LevelConfigBase):
pass

16
backend/app/schemas/auth.py Executable file
View File

@@ -0,0 +1,16 @@
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Optional[str] = None
class UserRegister(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8)
full_name: str
region_code: str = "HU"
device_id: str # Az eszköz egyedi azonosítója a védelemhez

56
backend/app/schemas/fleet.py Executable file
View File

@@ -0,0 +1,56 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import date, datetime
from app.models.expense import ExpenseCategory
# --- Vehicle Schemas ---
class VehicleBase(BaseModel):
license_plate: str
make: str
model: str
year: int
fuel_type: Optional[str] = None
vin: Optional[str] = None
initial_odometer: int = 0
mot_expiry_date: Optional[date] = None
insurance_expiry_date: Optional[date] = None
class VehicleCreate(VehicleBase):
pass
class VehicleResponse(VehicleBase):
id: int
current_odometer: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# --- Event / Expense Schemas ---
class EventBase(BaseModel):
event_type: ExpenseCategory
date: date
odometer_value: int
cost_amount: int
description: Optional[str] = None
is_diy: bool = False
# Ad-Hoc Provider mező: Ha stringet kapunk, a service megkeresi vagy létrehozza
provider_name: Optional[str] = None
provider_id: Optional[int] = None # Ha már ismert ID-t küldünk
class EventCreate(EventBase):
pass
class EventResponse(EventBase):
id: int
vehicle_id: int
odometer_anomaly: bool
service_provider_id: Optional[int]
image_paths: Optional[List[str]] = []
model_config = ConfigDict(from_attributes=True)
class TCOStats(BaseModel):
vehicle_id: int
total_cost: int
breakdown: dict[str, int] # Kategóriánkénti bontás
cost_per_km: Optional[float] = 0.0

60
backend/app/schemas/social.py Executable file
View File

@@ -0,0 +1,60 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import datetime
from app.models.social import ModerationStatus, SourceType
# --- Alap Sémák ---
class ServiceProviderBase(BaseModel):
name: str
address: Optional[str] = None
category: Optional[str] = None
source: SourceType = SourceType.manual
class ServiceProviderCreate(BaseModel):
name: str
address: str
category: Optional[str] = None
class ServiceProviderResponse(ServiceProviderBase):
id: int
status: ModerationStatus
validation_score: int # Látni kell a pontszámot
evidence_image_path: Optional[str] = None
added_by_user_id: Optional[int] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# --- Voting & Gamification Sémák ---
class VoteCreate(BaseModel):
vote_value: int # Csak a +1 vagy -1 kell, a user_id jön a tokenből, a provider_id az URL-ből
class LeaderboardEntry(BaseModel):
username: str
points: int
rank: int
model_config = ConfigDict(from_attributes=True)
# --- GAMIFIKÁCIÓS SÉMÁK (Amiket a log keresett) ---
class BadgeSchema(BaseModel):
id: int
name: str
description: str
image_url: Optional[str] = None
class Config:
from_attributes = True
class UserStatSchema(BaseModel):
user_id: int
total_points: int
current_level: int
rank_title: str
badges: List[BadgeSchema] = []
class Config:
from_attributes = True

10
backend/app/schemas/token.py Executable file
View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
from typing import Optional
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
role: Optional[str] = None

52
backend/app/schemas/user.py Executable file
View File

@@ -0,0 +1,52 @@
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional
from datetime import date
# Alap adatok, amik mindenhol kellenek
class UserBase(BaseModel):
email: EmailStr
first_name: Optional[str] = None
last_name: Optional[str] = None
is_active: Optional[bool] = True
is_superuser: bool = False
region_code: str = "HU"
# --- REGISZTRÁCIÓ ---
class UserRegister(UserBase):
password: str
birthday: Optional[date] = None
is_company: bool = False
company_name: Optional[str] = None
tax_number: Optional[str] = None
@field_validator('email')
@classmethod
def block_temporary_emails(cls, v: str) -> str:
blacklist = ['mailinator.com', '10minutemail.com', 'temp-mail.org', 'guerrillamail.com']
domain = v.split('@')[-1].lower()
if domain in blacklist:
raise ValueError('Ideiglenes email szolgáltató nem engedélyezett!')
return v
@field_validator('tax_number')
@classmethod
def validate_tax_id(cls, v: Optional[str], info) -> Optional[str]:
if info.data.get('is_company') and (not v or len(v) < 8):
raise ValueError('Cég esetén az adószám első 8 karaktere kötelező!')
return v
# --- VÁLASZ (Ezt hiányolta a rendszer!) ---
class UserResponse(UserBase):
id: int
is_company: bool
company_name: Optional[str] = None
# Pydantic V2 konfiguráció az ORM (SQLAlchemy) támogatáshoz
model_config = ConfigDict(from_attributes=True)
# Frissítéshez használt séma
class UserUpdate(BaseModel):
password: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[EmailStr] = None

30
backend/app/schemas/vehicle.py Executable file
View File

@@ -0,0 +1,30 @@
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Any
from uuid import UUID
from datetime import datetime
class EngineSpecBase(BaseModel):
engine_code: str
fuel_type: str
power_kw: int
default_service_interval_km: int = 15000
class VehicleBase(BaseModel):
brand_id: int
model_name: str
identification_number: str
license_plate: Optional[str] = None
tracking_mode: str = "km"
class VehicleCreate(VehicleBase):
current_company_id: int
engine_spec_id: int
class VehicleRead(VehicleBase):
id: UUID
current_rating_pct: int
total_real_usage: float
created_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,38 @@
import asyncio
import httpx
from sqlalchemy import text
from app.db.session import engine
from datetime import datetime
async def log_discovery(conn, category, brand, model, action):
await conn.execute(text("""
INSERT INTO data.bot_discovery_logs (category, brand_name, model_name, action_taken)
VALUES (:c, :b, :m, :a)
"""), {"c": category, "b": brand, "m": model, "a": action})
async def run_discovery():
async with engine.begin() as conn:
print(f"🚀 Jármű felfedezés indul: {datetime.now()}")
# Jelenleg a CAR kategóriára fókuszálunk egy külső API segítségével (pl. NHTSA - Ingyenes)
# Itt egy példa, hogyan bővül dinamikusan a rendszer
async with httpx.AsyncClient() as client:
# Autók lekérése
response = await client.get("https://vpic.nhtsa.dot.gov/api/vehicles/getallmakes?format=json")
if response.status_code == 200:
makes = response.json().get('Results', [])[:100] # Tesztként az első 100
for make in makes:
brand_name = make['Make_Name'].strip()
# Megnézzük, megvan-e már
res = await conn.execute(text("SELECT id FROM data.vehicle_brands WHERE name = :n"), {"n": brand_name})
if not res.scalar():
await conn.execute(text("INSERT INTO data.vehicle_brands (category_id, name) VALUES (1, :n)"), {"n": brand_name})
await log_discovery(conn, "CAR", brand_name, "ALL", "NEW_BRAND")
print(f"✨ Új márka találva: {brand_name}")
await conn.commit()
print("✅ Bot futása befejeződött.")
if __name__ == "__main__":
asyncio.run(run_discovery())

23
backend/app/seed_catalog.py Executable file
View File

@@ -0,0 +1,23 @@
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
from app.models.vehicle_catalog import VehicleCategory, VehicleMake
async def quick_seed():
async with AsyncSessionLocal() as db:
print("🌱 Alapkategóriák és márkák feltöltése...")
# 1. Kategóriák
cats = [VehicleCategory(name_key="CAR"), VehicleCategory(name_key="MOTORCYCLE"), VehicleCategory(name_key="LCV")]
db.add_all(cats)
# 2. Top Márkák (induláshoz)
makes = ["Audi", "BMW", "Honda", "Skoda", "Volkswagen", "Toyota", "Ford", "Yamaha", "Suzuki"]
for m_name in makes:
db.add(VehicleMake(name=m_name))
await db.commit()
print("✅ Kész! Most már van mihez modellt rendelni.")
if __name__ == "__main__":
asyncio.run(quick_seed())

118
backend/app/seed_data.py Executable file
View File

@@ -0,0 +1,118 @@
import asyncio
import sys
import os
# Útvonal beállítása
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
# --- JAVÍTÁS 1: A Helyes Aszinkron Session Importálása ---
from app.db.session import AsyncSessionLocal
# ---------------------------------------------------------
from app.models.user import User
from app.models.social import ServiceProvider, Competition, ModerationStatus
from app.services.social_service import vote_for_provider
from sqlalchemy import text
from datetime import datetime, timedelta
async def run_simulation():
# --- JAVÍTÁS 2: Itt is az AsyncSessionLocal-t használjuk ---
async with AsyncSessionLocal() as db:
# -----------------------------------------------------------
print("--- 1. TAKARÍTÁS (Előző tesztadatok törlése) ---")
# Kaszkádolt törlés a data sémában
await db.execute(text("TRUNCATE TABLE data.user_scores, data.votes, data.service_providers, data.competitions, data.users RESTART IDENTITY CASCADE"))
await db.commit()
print("\n--- 2. SZEREPLŐK LÉTREHOZÁSA ---")
# Admin
admin = User(email="admin@test.com", password_hash="hash", full_name="Admin", is_superuser=True)
# Jófiú (aki valós boltokat tölt fel)
good_user = User(email="good@test.com", password_hash="hash", full_name="Good Guy", reputation_score=5)
# Rosszfiú (aki fake boltokat tölt fel)
bad_user = User(email="bad@test.com", password_hash="hash", full_name="Spammer", reputation_score=-8) # Közel a banhoz
# Szavazóközönség
voter1 = User(email="voter1@test.com", password_hash="hash", full_name="Voter 1")
voter2 = User(email="voter2@test.com", password_hash="hash", full_name="Voter 2")
voter3 = User(email="voter3@test.com", password_hash="hash", full_name="Voter 3")
voter4 = User(email="voter4@test.com", password_hash="hash", full_name="Voter 4")
voter5 = User(email="voter5@test.com", password_hash="hash", full_name="Voter 5")
db.add_all([admin, good_user, bad_user, voter1, voter2, voter3, voter4, voter5])
await db.commit()
# ID-k lekérése
for u in [good_user, bad_user, voter1, voter2, voter3, voter4, voter5]:
await db.refresh(u)
print("\n--- 3. VERSENY INDÍTÁSA ---")
race = Competition(
name="Nagy Januári Verseny",
description="Töltsd fel a legtöbb boltot!",
start_date=datetime.utcnow() - timedelta(days=1),
end_date=datetime.utcnow() + timedelta(days=30),
is_active=True
)
db.add(race)
await db.commit()
await db.refresh(race)
print("\n--- 4. SZCENÁRIÓ A: A JÓ FELHASZNÁLÓ ---")
# Good Guy feltölt egy boltot
good_shop = ServiceProvider(
name="Korrekt Gumiszerviz",
address="Fő utca 1.",
added_by_user_id=good_user.id,
status=ModerationStatus.pending
)
db.add(good_shop)
await db.commit()
await db.refresh(good_shop)
# A tömeg megszavazza (Kell 5 pont az elfogadáshoz)
print(f"Szavazás a '{good_shop.name}' boltra...")
await vote_for_provider(db, voter1.id, good_shop.id, 1)
await vote_for_provider(db, voter2.id, good_shop.id, 1)
await vote_for_provider(db, voter3.id, good_shop.id, 1)
await vote_for_provider(db, voter4.id, good_shop.id, 1)
await vote_for_provider(db, voter5.id, good_shop.id, 1) # Itt éri el az 5-öt!
# Eredmény ellenőrzése
await db.refresh(good_user)
print(f"Good Guy Hírneve (Elvárt: 6): {good_user.reputation_score}")
# Pontszám ellenőrzése
points = await db.execute(text(f"SELECT points FROM data.user_scores WHERE user_id={good_user.id}"))
scalar_points = points.scalar()
print(f"Good Guy Verseny Pontjai (Elvárt: 10): {scalar_points}")
print("\n--- 5. SZCENÁRIÓ B: A ROSSZ FELHASZNÁLÓ (AUTO-BAN TESZT) ---")
# Bad Guy feltölt egy fake boltot
fake_shop = ServiceProvider(
name="KAMU Bolt",
address="Nincs ilyen utca",
added_by_user_id=bad_user.id,
status=ModerationStatus.pending
)
db.add(fake_shop)
await db.commit()
await db.refresh(fake_shop)
# A tömeg leszavazza (Kell -3 az elutasításhoz)
print(f"Szavazás a '{fake_shop.name}' boltra...")
await vote_for_provider(db, voter1.id, fake_shop.id, -1)
await vote_for_provider(db, voter2.id, fake_shop.id, -1)
await vote_for_provider(db, voter3.id, fake_shop.id, -1) # Itt éri el a -3-at!
# Eredmény ellenőrzése
await db.refresh(bad_user)
print(f"Bad User Hírneve (Elvárt: -10): {bad_user.reputation_score}")
print(f"Bad User Aktív? (Elvárt: False/Banned): {bad_user.is_active}")
if not bad_user.is_active:
print("✅ SIKER: A rendszer automatikusan kitiltotta a csalót!")
else:
print("❌ HIBA: A felhasználó még mindig aktív.")
if __name__ == "__main__":
asyncio.run(run_simulation())

46
backend/app/seed_honda.py Executable file
View File

@@ -0,0 +1,46 @@
import asyncio
from sqlalchemy import text
from app.db.session import SessionLocal
async def seed():
async with SessionLocal() as db:
print("🚀 Honda adatok betöltése...")
# 1. Kategóriák (Autó, Motor) - Sima idézőjelekkel a SQL-ben
await db.execute(text("""
INSERT INTO data.vehicle_categories (name, slug)
VALUES (\u0027Személyautó\u0027, \u0027car\u0027), (\u0027Motorkerékpár\u0027, \u0027motorcycle\u0027)
ON CONFLICT (slug) DO NOTHING
"""))
# 2. Márka: Honda
res = await db.execute(text("""
INSERT INTO data.vehicle_brands (name, slug, country_code)
VALUES (\u0027Honda\u0027, \u0027honda\u0027, \u0027JP\u0027)
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id
"""))
brand_id = res.fetchone()[0]
# 3. Modellek listája
models = [
("Civic", "civic"),
("Accord", "accord"),
("CR-V", "cr-v"),
("Jazz", "jazz"),
("HR-V", "hr-v"),
("NSX", "nsx")
]
for name, slug in models:
await db.execute(text(f"""
INSERT INTO data.vehicle_models (brand_id, name, slug)
VALUES ({brand_id}, \u0027{name}\u0027, \u0027{slug}\u0027)
ON CONFLICT (brand_id, slug) DO NOTHING
"""))
await db.commit()
print("✅ Honda márka és modellek sikeresen betöltve!")
if __name__ == "__main__":
asyncio.run(seed())

58
backend/app/seed_system.py Executable file
View File

@@ -0,0 +1,58 @@
import asyncio
from datetime import datetime
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.legal import LegalDocument
from app.models.email_template import EmailTemplate, EmailType
from app.models.email_provider import EmailProviderConfig
async def seed_data():
async with SessionLocal() as db:
# 1. Jogi dokumentumok (HU)
legal_docs = [
LegalDocument(
title="Általános Szerződési Feltételek",
content="Ide jön az ÁSZF szövege... Kérjük görgessen az aljáig.",
version="v1.0",
region_code="HU",
language="hu"
),
LegalDocument(
title="Adatkezelési Tájékoztató (GDPR)",
content="Ide jön a GDPR szövege... Kérjük görgessen az aljáig.",
version="v1.0",
region_code="HU",
language="hu"
)
]
# 2. Email Sablon (Regisztráció)
reg_template = EmailTemplate(
type=EmailType.REGISTRATION,
subject="Üdvözöljük a Service Finderben!",
body_html="""
<h3>Kedves {{ name }}!</h3>
<p>Köszönjük a regisztrációt! Az aktiváláshoz kattints ide:</p>
<a href="{{ link }}">Fiók aktiválása</a>
<p>A link 24 óráig érvényes.</p>
"""
)
# 3. Email Szolgáltató (SendGrid)
sendgrid_provider = EmailProviderConfig(
name="SendGrid_Primary",
provider_type="SENDGRID",
priority=1,
settings={"api_key": "YOUR_SENDGRID_KEY_HERE"}, # Ezt majd az adminon írjuk át
max_fail_threshold=3
)
db.add_all(legal_docs)
db.add(reg_template)
db.add(sendgrid_provider)
await db.commit()
print("🌱 Alapadatok sikeresen feltöltve!")
if __name__ == "__main__":
asyncio.run(seed_data())

View File

@@ -0,0 +1,41 @@
from typing import Any, Optional
from sqlalchemy import text
from app.db.session import SessionLocal
class ConfigService:
@staticmethod
async def get_setting(
key: str,
org_id: Optional[int] = None,
region_code: Optional[str] = None,
tier_id: Optional[int] = None,
default: Any = None
) -> Any:
query = text("""
SELECT value_json
FROM data.system_settings
WHERE key_name = :key
AND (
(org_id = :org_id) OR
(org_id IS NULL AND tier_id = :tier_id) OR
(org_id IS NULL AND tier_id IS NULL AND region_code = :region_code) OR
(org_id IS NULL AND tier_id IS NULL AND region_code IS NULL)
)
ORDER BY
(org_id IS NOT NULL) DESC,
(tier_id IS NOT NULL) DESC,
(region_code IS NOT NULL) DESC
LIMIT 1
""")
async with SessionLocal() as db:
result = await db.execute(query, {
"key": key,
"org_id": org_id,
"tier_id": tier_id,
"region_code": region_code
})
row = result.fetchone()
return row[0] if row else default
config = ConfigService()

View File

@@ -0,0 +1,84 @@
import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from app.core.config import settings
class EmailManager:
@staticmethod
def _render_template(template_key: str, variables: dict, lang: str = "hu") -> str:
base_dir = "/app/app/templates/emails"
file_path = f"{base_dir}/{lang}/{template_key}.html"
if not os.path.exists(file_path):
return ""
with open(file_path, "r", encoding="utf-8") as f:
body_html = f.read()
for k, v in variables.items():
body_html = body_html.replace(f"{{{{{k}}}}}", str(v))
body_html = body_html.replace(f"{{{k}}}", str(v))
return body_html
@staticmethod
def _subject(template_key: str) -> str:
subjects = {
"registration": "Regisztráció - Service Finder",
"password_reset": "Jelszó visszaállítás - Service Finder",
"notification": "Értesítés - Service Finder",
}
return subjects.get(template_key, "Értesítés - Service Finder")
@staticmethod
async def send_email(recipient: str, template_key: str, variables: dict, user_id: int = None, lang: str = "hu"):
if settings.EMAIL_PROVIDER == "disabled":
return {"status": "disabled"}
html = EmailManager._render_template(template_key, variables, lang=lang)
subject = EmailManager._subject(template_key)
provider = settings.EMAIL_PROVIDER
if provider == "auto":
provider = "sendgrid" if settings.SENDGRID_API_KEY else "smtp"
# 1) SendGrid API (stabil)
if provider == "sendgrid" and settings.SENDGRID_API_KEY:
try:
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
message = Mail(
from_email=(settings.EMAILS_FROM_EMAIL, settings.EMAILS_FROM_NAME),
to_emails=recipient,
subject=subject,
html_content=html or "<p>Üzenet</p>",
)
sg = SendGridAPIClient(settings.SENDGRID_API_KEY)
sg.send(message)
return {"status": "success", "provider": "sendgrid"}
except Exception as e:
# ha auto módban vagyunk, esünk vissza smtp-re
if settings.EMAIL_PROVIDER != "auto":
return {"status": "error", "provider": "sendgrid", "message": str(e)}
# 2) SMTP fallback
if not settings.SMTP_HOST or not settings.SMTP_USER or not settings.SMTP_PASSWORD:
return {"status": "error", "provider": "smtp", "message": "SMTP not configured"}
try:
msg = MIMEMultipart()
msg["From"] = f"{settings.EMAILS_FROM_NAME} <{settings.EMAILS_FROM_EMAIL}>"
msg["To"] = recipient
msg["Subject"] = subject
msg.attach(MIMEText(html or "Üzenet", "html"))
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=15) as server:
if settings.SMTP_USE_TLS:
server.starttls()
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.send_message(msg)
return {"status": "success", "provider": "smtp"}
except Exception as e:
return {"status": "error", "provider": "smtp", "message": str(e)}
email_manager = EmailManager()

View File

@@ -0,0 +1,40 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.models.vehicle import UserVehicle
from app.models.expense import VehicleEvent
from app.models.social import ServiceProvider, SourceType, ModerationStatus
from app.schemas.fleet import EventCreate, TCOStats
from app.services.gamification_service import GamificationService
async def add_vehicle_event(db: AsyncSession, vehicle_id: int, event_data: EventCreate, user_id: int):
v_res = await db.execute(select(UserVehicle).where(UserVehicle.id == vehicle_id))
vehicle = v_res.scalars().first()
if not vehicle: return {"error": "Vehicle not found"}
final_provider_id = event_data.provider_id
if event_data.is_diy: final_provider_id = None
elif event_data.provider_name and not final_provider_id:
p_res = await db.execute(select(ServiceProvider).where(func.lower(ServiceProvider.name) == event_data.provider_name.lower()))
existing = p_res.scalars().first()
if existing: final_provider_id = existing.id
else:
new_p = ServiceProvider(name=event_data.provider_name, added_by_user_id=user_id, status=ModerationStatus.pending)
db.add(new_p); await db.flush(); final_provider_id = new_p.id
await GamificationService.award_points(db, user_id, 50, f"Új helyszín: {event_data.provider_name}")
anomaly = event_data.odometer_value < vehicle.current_odometer
new_event = VehicleEvent(vehicle_id=vehicle_id, service_provider_id=final_provider_id, odometer_anomaly=anomaly, **event_data.model_dump(exclude={"provider_id", "provider_name"}))
db.add(new_event)
if event_data.odometer_value > vehicle.current_odometer: vehicle.current_odometer = event_data.odometer_value
await GamificationService.award_points(db, user_id, 20, f"Esemény: {event_data.event_type}")
await db.commit(); await db.refresh(new_event)
return new_event
async def calculate_tco(db: AsyncSession, vehicle_id: int) -> TCOStats:
result = await db.execute(select(VehicleEvent.event_type, func.sum(VehicleEvent.cost_amount)).where(VehicleEvent.vehicle_id == vehicle_id).group_by(VehicleEvent.event_type))
breakdown = {row[0]: row[1] for row in result.all()}
v_res = await db.execute(select(UserVehicle).where(UserVehicle.id == vehicle_id))
v = v_res.scalars().first()
km = (v.current_odometer - v.initial_odometer) if v else 0
cpk = sum(breakdown.values()) / km if km > 0 else 0
return TCOStats(vehicle_id=vehicle_id, total_cost=sum(breakdown.values()), breakdown=breakdown, cost_per_km=round(cpk, 2))

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