Initial commit: Robot ökoszisztéma v2.0 - Stabilizált jármű és szerviz robotok

This commit is contained in:
Kincses
2026-03-04 02:03:03 +01:00
commit 250f4f4b8f
7942 changed files with 449625 additions and 0 deletions

132
backend/app/api/auth.py.old 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")

139
backend/app/api/deps.py Executable file
View File

@@ -0,0 +1,139 @@
# /opt/docker/dev/service_finder/backend/app/api/deps.py
from typing import Optional, Dict, Any, Union
import logging
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.core.security import decode_token, DEFAULT_RANK_MAP
from app.models.identity import User, UserRole # JAVÍTVA: Új Identity modell használata
from app.core.config import settings
logger = logging.getLogger(__name__)
# --- GONDOLATMENET / THOUGHT PROCESS ---
# 1. Az OAuth2 folyamat a központosított bejelentkezési végponton keresztül fut.
# 2. A token visszafejtésekor ellenőrizni kell a 'type' mezőt, hogy ne lehessen refresh tokennel belépni.
# 3. A felhasználó lekérésekor a SQLAlchemy 2.0 aszinkron 'execute' és 'scalar_one_or_none' metódusait használjuk.
# 4. A Scoped RBAC (Role-Based Access Control) biztosítja, hogy a felhasználók ne férjenek hozzá egymás flottáihoz.
# ---------------------------------------
# Az OAuth2 folyamat a bejelentkezési végponton keresztül
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
async def get_current_token_payload(
token: str = Depends(reusable_oauth2)
) -> Dict[str, Any]:
"""
JWT token visszafejtése és a típus (access) ellenőrzése.
"""
# Fejlesztői bypass (opcionális, csak DEBUG módban)
if settings.DEBUG and token == "dev_bypass_active":
return {
"sub": "1",
"role": "superadmin",
"rank": 100,
"scope_level": "global",
"scope_id": "all",
"type": "access"
}
payload = decode_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen vagy lejárt munkamenet."
)
return payload
async def get_current_user(
db: AsyncSession = Depends(get_db),
payload: Dict = Depends(get_current_token_payload)
) -> User:
"""
Lekéri a felhasználót a token 'sub' mezője alapján (SQLAlchemy 2.0 aszinkron módon).
"""
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token azonosítási hiba."
)
# JAVÍTVA: Modern SQLAlchemy 2.0 aszinkron lekérdezés
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user or user.is_deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A felhasználó nem található."
)
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Ellenőrzi, hogy a felhasználó aktív-e (KYC Step 2 kész).
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="A művelethez aktív profil és KYC azonosítás szükséges."
)
return current_user
async def check_resource_access(
resource_scope_id: Union[str, int],
current_user: User = Depends(get_current_user)
):
"""
Scoped RBAC: Megakadályozza a jogosulatlan hozzáférést mások adataihoz.
"""
if current_user.role == UserRole.superadmin:
return True
user_scope = str(current_user.scope_id) if current_user.scope_id else None
requested_scope = str(resource_scope_id)
# 1. Saját ID ellenőrzése
if str(current_user.id) == requested_scope:
return True
# 2. Szervezeti/Flotta scope ellenőrzése
if user_scope and user_scope == requested_scope:
return True
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nincs jogosultsága ehhez az erőforráshoz."
)
def check_min_rank(role_key: str):
"""
Dinamikus Rank ellenőrzés a system_parameters tábla alapján.
"""
async def rank_checker(
db: AsyncSession = Depends(get_db),
payload: Dict = Depends(get_current_token_payload)
):
# A settings.get_db_setting-et használjuk a dinamikus lekéréshez
ranks = await settings.get_db_setting(
db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP
)
required_rank = ranks.get(role_key, 0)
user_rank = payload.get("rank", 0)
if user_rank < required_rank:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Alacsony jogosultsági szint. (Elvárt: {required_rank})"
)
return True
return rank_checker

17
backend/app/api/recommend.py Executable file
View File

@@ -0,0 +1,17 @@
# /opt/docker/dev/service_finder/backend/app/api/recommend.py
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from app.db.session import get_db
router = APIRouter()
@router.get("/provider/inbox")
async def provider_inbox(provider_id: str, db: AsyncSession = Depends(get_db)):
""" Aszinkron szerviz-postaláda lekérdezés. """
query = text("""
SELECT * FROM data.service_profiles
WHERE id = :p_id
""")
result = await db.execute(query, {"p_id": provider_id})
return [dict(row._mapping) for row in result.fetchall()]

20
backend/app/api/v1/api.py Executable file
View File

@@ -0,0 +1,20 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/api.py
from fastapi import APIRouter
from app.api.v1.endpoints import (
auth, catalog, assets, organizations, documents,
services, admin, expenses, evidence, social
)
api_router = APIRouter()
# Minden modul az új, refaktorált végpontokra mutat
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(services.router, prefix="/services", tags=["Service Hunt & Discovery"])
api_router.include_router(catalog.router, prefix="/catalog", tags=["Vehicle Catalog"])
api_router.include_router(assets.router, prefix="/assets", tags=["Assets"])
api_router.include_router(organizations.router, prefix="/organizations", tags=["Organizations"])
api_router.include_router(documents.router, prefix="/documents", tags=["Documents"])
api_router.include_router(admin.router, prefix="/admin", tags=["Admin Control Center (Sentinel)"])
api_router.include_router(evidence.router, prefix="/evidence", tags=["Evidence & OCR (Robot 3)"])
api_router.include_router(expenses.router, prefix="/expenses", tags=["Fleet Expenses (TCO)"])
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])

View File

@@ -0,0 +1,131 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text, delete
from typing import List, Any, Dict, Optional
from datetime import datetime, timedelta
from app.api import deps
from app.models.identity import User, UserRole # JAVÍTVA: Központi import
from app.models.system import SystemParameter
# JAVÍTVA: Security audit modellek
from app.models.audit import SecurityAuditLog, OperationalLog
# JAVÍTVA: Ezek a modellek a security.py-ból jönnek (ha ott vannak)
from app.models.security import PendingAction, ActionStatus
from app.services.security_service import security_service
from app.services.translation_service import TranslationService
from pydantic import BaseModel
class ConfigUpdate(BaseModel):
key: str
value: Any
scope_level: str = "global"
scope_id: Optional[str] = None
category: str = "general"
router = APIRouter()
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
""" Csak Admin vagy Superadmin. """
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sentinel jogosultság szükséges!"
)
return current_user
@router.get("/health-monitor", tags=["Sentinel Monitoring"])
async def get_system_health(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
stats = {}
# Adatbázis statisztikák (Nyers SQL marad, mert hatékony)
user_stats = await db.execute(text("SELECT subscription_plan, count(*) FROM data.users GROUP BY subscription_plan"))
stats["user_distribution"] = {row[0]: row[1] for row in user_stats}
asset_count = await db.execute(text("SELECT count(*) FROM data.assets"))
stats["total_assets"] = asset_count.scalar()
org_count = await db.execute(text("SELECT count(*) FROM data.organizations"))
stats["total_organizations"] = org_count.scalar()
# JAVÍTVA: Biztonsági státusz az új SecurityAuditLog alapján
day_ago = datetime.now() - timedelta(days=1)
crit_logs = await db.execute(
select(func.count(SecurityAuditLog.id))
.where(
SecurityAuditLog.is_critical == True,
SecurityAuditLog.created_at >= day_ago
)
)
stats["critical_alerts_24h"] = crit_logs.scalar() or 0
return stats
@router.get("/pending-actions", response_model=List[Any], tags=["Sentinel Security"])
async def list_pending_actions(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
stmt = select(PendingAction).where(PendingAction.status == ActionStatus.pending)
result = await db.execute(stmt)
return result.scalars().all()
@router.post("/approve/{action_id}", tags=["Sentinel Security"])
async def approve_action(
action_id: int,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
try:
await security_service.approve_action(db, admin.id, action_id)
return {"status": "success", "message": "Művelet végrehajtva."}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/parameters", tags=["Dynamic Configuration"])
async def list_all_parameters(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
result = await db.execute(select(SystemParameter))
return result.scalars().all()
@router.post("/parameters", tags=["Dynamic Configuration"])
async def set_parameter(
config: ConfigUpdate,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
query = text("""
INSERT INTO data.system_parameters (key, value, scope_level, scope_id, category, last_modified_by)
VALUES (:key, :val, :sl, :sid, :cat, :user)
ON CONFLICT (key, scope_level, scope_id)
DO UPDATE SET
value = EXCLUDED.value,
category = EXCLUDED.category,
last_modified_by = EXCLUDED.last_modified_by,
updated_at = now()
""")
await db.execute(query, {
"key": config.key,
"val": config.value,
"sl": config.scope_level,
"sid": config.scope_id,
"cat": config.category,
"user": admin.email
})
await db.commit()
return {"status": "success", "message": f"'{config.key}' frissítve."}
@router.post("/translations/sync", tags=["System Utilities"])
async def sync_translations_to_json(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
await TranslationService.export_to_json(db)
return {"message": "JSON fájlok frissítve."}

View File

@@ -0,0 +1,54 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py
import uuid
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy.orm import selectinload
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models.asset import Asset, AssetCost
from app.models.identity import User
from app.services.cost_service import cost_service
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
from app.schemas.asset import AssetResponse
router = APIRouter()
@router.get("/{asset_id}/financial-summary", response_model=Dict[str, Any])
async def get_asset_financial_report(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
MB 2.0 Dinamikus Pénzügyi Riport.
Visszaadja a kategóriákra bontott és az összesített költségeket (Local/EUR).
"""
# 1. Jogosultság ellenőrzése (Csak a tulajdonos vagy admin láthatja)
# (Itt egy gyors check, hogy az asset az övé-e)
try:
return await cost_service.get_asset_financial_summary(db, asset_id)
except Exception as e:
raise HTTPException(status_code=500, detail="Hiba a riport generálásakor")
@router.get("/{asset_id}/costs", response_model=List[AssetCostResponse])
async def list_asset_costs(
asset_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Tételes költséglista lapozással (Pagination)."""
stmt = (
select(AssetCost)
.where(AssetCost.asset_id == asset_id)
.order_by(desc(AssetCost.date))
.offset(skip)
.limit(limit)
)
res = await db.execute(stmt)
return res.scalars().all()

View File

@@ -0,0 +1,41 @@
# backend/app/api/v1/endpoints/auth.py
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.services.auth_service import AuthService
from app.core.security import create_tokens, DEFAULT_RANK_MAP
from app.core.config import settings
from app.schemas.auth import UserLiteRegister, Token, UserKYCComplete
from app.api.deps import get_current_user
from app.models.identity import User # JAVÍTVA: Új központi modell
router = APIRouter()
@router.post("/login", response_model=Token)
async def login(db: AsyncSession = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()):
user = await AuthService.authenticate(db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=401, detail="Hibás adatok.")
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": ranks.get(role_name, 10),
"scope_level": user.scope_level or "individual",
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
}
access, refresh = create_tokens(data=token_data)
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer", "is_active": user.is_active}
@router.post("/complete-kyc")
async def complete_kyc(kyc_in: UserKYCComplete, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
user = await AuthService.complete_kyc(db, current_user.id, kyc_in)
if not user:
raise HTTPException(status_code=404, detail="User nem található.")
return {"status": "success", "message": "Fiók aktiválva."}

View File

@@ -0,0 +1,63 @@
# backend/app/api/v1/endpoints/billing.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.api.deps import get_db, get_current_user
from app.models.identity import User, Wallet, UserRole
from app.models.audit import FinancialLedger
from app.services.config_service import config
router = APIRouter()
@router.post("/upgrade")
async def upgrade_account(target_package: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""
Univerzális csomagváltó.
Kezeli az 5+ csomagot, a Rank-ugrást és a különleges 'Service Coin' bónuszokat.
"""
# 1. Lekérjük a teljes csomagmátrixot az adminból
# Példa JSON: {"premium": {"price": 2000, "rank": 5, "type": "credit"}, "service_pro": {"price": 10000, "rank": 30, "type": "coin"}}
package_matrix = await config.get_setting(db, "subscription_packages_matrix")
if target_package not in package_matrix:
raise HTTPException(status_code=400, detail="Érvénytelen csomagválasztás.")
pkg_info = package_matrix[target_package]
price = pkg_info["price"]
# 2. Pénztárca ellenőrzése
stmt = select(Wallet).where(Wallet.user_id == current_user.id)
wallet = (await db.execute(stmt)).scalar_one_or_none()
total_balance = wallet.purchased_credits + wallet.earned_credits
if total_balance < price:
raise HTTPException(status_code=402, detail="Nincs elég kredited a csomagváltáshoz.")
# 3. Levonási logika (Purchased -> Earned sorrend)
if wallet.purchased_credits >= price:
wallet.purchased_credits -= price
else:
remaining = price - wallet.purchased_credits
wallet.purchased_credits = 0
wallet.earned_credits -= remaining
# 4. Speciális Szerviz Logika (Service Coins)
# Ha a csomag típusa 'coin', akkor a szerviz kap egy kezdő Coin csomagot is
if pkg_info.get("type") == "coin":
initial_coins = pkg_info.get("initial_coin_bonus", 100)
wallet.service_coins += initial_coins
logger.info(f"User {current_user.id} upgraded to Service Pro, awarded {initial_coins} coins.")
# 5. Rang frissítése és naplózás
current_user.role = target_package # Pl. 'service_pro' vagy 'vip'
db.add(FinancialLedger(
user_id=current_user.id,
amount=-price,
transaction_type=f"UPGRADE_{target_package.upper()}",
details=pkg_info
))
await db.commit()
return {"status": "success", "package": target_package, "rank_granted": pkg_info["rank"]}

View File

@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.services.asset_service import AssetService
from typing import List
router = APIRouter()
@router.get("/makes", response_model=List[str])
async def list_makes(db: AsyncSession = Depends(get_db)):
"""1. Szint: Márkák listázása."""
return await AssetService.get_makes(db)
@router.get("/models", response_model=List[str])
async def list_models(make: str, db: AsyncSession = Depends(get_db)):
"""2. Szint: Típusok listázása egy adott márkához."""
models = await AssetService.get_models(db, make)
if not models:
raise HTTPException(status_code=404, detail="Márka nem található vagy nincsenek típusok.")
return models
@router.get("/generations", response_model=List[str])
async def list_generations(make: str, model: str, db: AsyncSession = Depends(get_db)):
"""3. Szint: Generációk/Évjáratok listázása."""
generations = await AssetService.get_generations(db, make, model)
if not generations:
raise HTTPException(status_code=404, detail="Nincs generációs adat ehhez a típushoz.")
return generations
@router.get("/engines")
async def list_engines(make: str, model: str, gen: str, db: AsyncSession = Depends(get_db)):
"""4. Szint: Motorváltozatok és technikai specifikációk."""
engines = await AssetService.get_engines(db, make, model, gen)
if not engines:
raise HTTPException(status_code=404, detail="Nincs motorváltozat adat.")
# Itt visszaküldjük a teljes katalógus objektumokat (ID, motorváltozat, specifikációk)
return [
{
"id": e.id,
"variant": e.engine_variant,
"engine_code": e.engine_code,
"fuel_type": e.fuel_type,
"factory_data": e.factory_data
} for e in engines
]

View File

@@ -0,0 +1,87 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/documents.py
import uuid
from typing import Any, Dict
from fastapi import APIRouter, Depends, UploadFile, File, Form, BackgroundTasks, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.api.deps import get_current_user
from app.services.document_service import DocumentService
from app.models.identity import User
router = APIRouter()
@router.post("/upload/{parent_type}/{parent_id}")
async def upload_document(
parent_type: str,
parent_id: str,
background_tasks: BackgroundTasks,
doc_type: str = Form(..., description="A dokumentum típusa: 'invoice', 'registration_card', 'sale_contract'"),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
MB 2.0 Dokumentum Pipeline.
1. Ellenőrzi a felhasználó havi OCR kvótáját (Admin 2.0).
2. Optimalizálja és NAS-ra menti a képet (WebP konverzió).
3. Automatikusan elindítja a Robot 1-et (OCR), ha a típus engedélyezett.
"""
# 1. Bemeneti validáció
valid_parents = ["organizations", "assets", "transfers"]
if parent_type not in valid_parents:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Érvénytelen cél-típus! Megengedett: {', '.join(valid_parents)}"
)
try:
# 2. Feldolgozás a szolgáltatás rétegben
# Itt történik a kvóta-ellenőrzés és a Robot 1 triggerelése is
doc = await DocumentService.process_upload(
db=db,
user_id=current_user.id,
file=file,
parent_type=parent_type,
parent_id=parent_id,
doc_type=doc_type,
background_tasks=background_tasks
)
# 3. Válasz összeállítása az állapot alapján
response_data = {
"document_id": doc.id,
"original_name": doc.original_name,
"status": doc.status,
"thumbnail": doc.thumbnail_path,
}
if doc.status == "processing":
response_data["message"] = "🤖 Robot 1 megkezdte a dokumentum elemzését. Értesítjük, ha kész!"
else:
response_data["message"] = "Dokumentum sikeresen archiválva a széfben."
return response_data
except HTTPException as he:
# Közvetlenül átengedjük a service-ből jövő (pl. kvóta) hibákat
raise he
except ValueError as ve:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(ve))
except Exception as e:
# Sentinel naplózás és általános hiba
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Kritikus hiba a dokumentum feldolgozása során."
)
@router.get("/{document_id}/status")
async def get_document_status(
document_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Lekérdezhető, hogy a robot végzett-e már a feldolgozással."""
# (Itt egy egyszerű lekérdezés a Document táblából a státuszra)
pass

View File

@@ -0,0 +1,24 @@
# backend/app/api/v1/endpoints/evidence.py
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text
from app.api.deps import get_db, get_current_user
from app.models.identity import User
from app.models.asset import Asset # JAVÍTVA: Asset modell
router = APIRouter()
@router.post("/scan-registration")
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
stmt_limit = text("SELECT (value->>:plan)::int FROM data.system_parameters WHERE key = 'VEHICLE_LIMIT'")
res = await db.execute(stmt_limit, {"plan": current_user.subscription_plan or "free"})
max_allowed = res.scalar() or 1
stmt_count = select(func.count(Asset.id)).where(Asset.owner_organization_id == current_user.scope_id)
count = (await db.execute(stmt_count)).scalar() or 0
if count >= max_allowed:
raise HTTPException(status_code=403, detail=f"Limit túllépés: {max_allowed} jármű engedélyezett.")
# OCR hívás helye...
return {"success": True, "message": "Feldolgozás megkezdődött."}

View File

@@ -0,0 +1,33 @@
# backend/app/api/v1/endpoints/expenses.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.api.deps import get_db, get_current_user
from app.models.asset import Asset, AssetCost # JAVÍTVA
from pydantic import BaseModel
from datetime import date
router = APIRouter()
class ExpenseCreate(BaseModel):
asset_id: str
category: str
amount: float
date: date
@router.post("/add")
async def add_expense(expense: ExpenseCreate, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
stmt = select(Asset).where(Asset.id == expense.asset_id)
if not (await db.execute(stmt)).scalar_one_or_none():
raise HTTPException(status_code=404, detail="Jármű nem található.")
new_cost = AssetCost(
asset_id=expense.asset_id,
cost_type=expense.category,
amount_local=expense.amount,
date=expense.date,
currency_local="HUF"
)
db.add(new_cost)
await db.commit()
return {"status": "success"}

View File

@@ -0,0 +1,40 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/gamification.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from typing import List
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models.identity import User
from app.models.gamification import UserStats, PointsLedger
from app.services.config_service import config
router = APIRouter()
@router.get("/my-stats")
async def get_my_stats(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""A bejelentkezett felhasználó aktuális XP-je, szintje és büntetőpontjai."""
stmt = select(UserStats).where(UserStats.user_id == current_user.id)
stats = (await db.execute(stmt)).scalar_one_or_none()
if not stats:
return {"total_xp": 0, "current_level": 1, "penalty_points": 0}
return stats
@router.get("/leaderboard")
async def get_leaderboard(limit: int = 10, db: AsyncSession = Depends(get_db)):
"""A 10 legtöbb XP-vel rendelkező felhasználó listája."""
stmt = (
select(User.email, UserStats.total_xp, UserStats.current_level)
.join(UserStats, User.id == UserStats.user_id)
.order_by(desc(UserStats.total_xp))
.limit(limit)
)
result = await db.execute(stmt)
# Az email-eket maszkoljuk a GDPR miatt (pl. k***s@p***.hu)
return [
{"user": f"{r[0][:2]}***@{r[0].split('@')[1]}", "xp": r[1], "level": r[2]}
for r in result.all()
]

View File

@@ -0,0 +1,100 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/notifications.py
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, desc, func
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models.identity import User
from app.models.system import InternalNotification
from app.schemas.social import NotificationResponse, NotificationUpdate # Feltételezett sémák
router = APIRouter()
@router.get("/my", response_model=List[NotificationResponse])
async def get_my_notifications(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
unread_only: bool = False,
limit: int = Query(50, ge=1, le=100),
offset: int = 0
):
"""
Lekéri a bejelentkezett felhasználó értesítéseit.
Támogatja a szűrést az olvasatlanokra és a lapozást.
"""
stmt = (
select(InternalNotification)
.where(InternalNotification.user_id == current_user.id)
)
if unread_only:
stmt = stmt.where(InternalNotification.is_read == False)
stmt = stmt.order_by(desc(InternalNotification.created_at)).offset(offset).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
@router.post("/mark-read")
async def mark_as_read(
notification_ids: List[uuid.UUID],
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Egy vagy több értesítés olvasottnak jelölése.
"""
if not notification_ids:
raise HTTPException(status_code=400, detail="Nincs megadva azonosító.")
stmt = (
update(InternalNotification)
.where(InternalNotification.id.in_(notification_ids))
.where(InternalNotification.user_id == current_user.id)
.values(is_read=True, read_at=func.now())
)
await db.execute(stmt)
await db.commit()
return {"status": "success", "marked_count": len(notification_ids)}
@router.post("/mark-all-read")
async def mark_all_read(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
A felhasználó összes értesítésének olvasottnak jelölése.
"""
stmt = (
update(InternalNotification)
.where(InternalNotification.user_id == current_user.id)
.where(InternalNotification.is_read == False)
.values(is_read=True, read_at=func.now())
)
await db.execute(stmt)
await db.commit()
return {"status": "success", "message": "Minden értesítés olvasottnak jelölve."}
@router.get("/summary")
async def get_notification_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Gyors összesítő a Dashboard-hoz (pl. hány olvasatlan van).
"""
stmt = (
select(func.count(InternalNotification.id))
.where(InternalNotification.user_id == current_user.id)
.where(InternalNotification.is_read == False)
)
unread_count = (await db.execute(stmt)).scalar() or 0
return {"unread_count": unread_count}

View File

@@ -0,0 +1,115 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/organizations.py
import os
import re
import uuid
import hashlib
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.api.deps import get_current_user
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse
from app.models.organization import Organization, OrgType, OrganizationMember
from app.models.identity import User # JAVÍTVA: Központi Identity modell
from app.core.config import settings
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/onboard", response_model=CorpOnboardResponse, status_code=status.HTTP_201_CREATED)
async def onboard_organization(
org_in: CorpOnboardIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Új szervezet (cég/szerviz) rögzítése.
Automatikusan generál slug-ot és létrehozza a NAS mappa-struktúrát.
"""
# 1. Magyar adószám validáció (XXXXXXXX-Y-ZZ)
if org_in.country_code == "HU":
if not re.match(r"^\d{8}-\d-\d{2}$", org_in.tax_number):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Érvénytelen magyar adószám formátum!"
)
# 2. Duplikáció ellenőrzés
stmt_exist = select(Organization).where(Organization.tax_number == org_in.tax_number)
result_exist = await db.execute(stmt_exist)
if result_exist.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Ezzel az adószámmal már regisztráltak céget!"
)
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
# Mivel az adatbázisban NOT NULL, itt muszáj létrehozni
temp_slug = hashlib.md5(f"{org_in.tax_number}-{uuid.uuid4()}".encode()).hexdigest()[:12]
# 4. Mentés
new_org = Organization(
full_name=org_in.full_name,
name=org_in.name,
display_name=org_in.display_name,
tax_number=org_in.tax_number,
reg_number=org_in.reg_number,
folder_slug=temp_slug, # JAVÍTVA: Kötelező mező beillesztve
address_zip=org_in.address_zip,
address_city=org_in.address_city,
address_street_name=org_in.address_street_name,
address_street_type=org_in.address_street_type,
address_house_number=org_in.address_house_number,
address_hrsz=org_in.address_hrsz,
address_stairwell=org_in.address_stairwell,
address_floor=org_in.address_floor,
address_door=org_in.address_door,
country_code=org_in.country_code,
org_type=OrgType.business,
status="pending_verification"
)
db.add(new_org)
await db.flush()
# 5. TULAJDONOS RÖGZÍTÉSE
owner_member = OrganizationMember(
organization_id=new_org.id,
user_id=current_user.id,
role="OWNER" # JAVÍTVA: Enum kompatibilis nagybetűs forma
)
db.add(owner_member)
# 6. NAS Mappa létrehozása
try:
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data")
org_path = os.path.join(base_path, "organizations", str(new_org.id))
os.makedirs(os.path.join(org_path, "documents"), exist_ok=True)
logger.info(f"NAS mappa kész: {org_path}")
except Exception as e:
logger.error(f"NAS hiba: {e}")
await db.commit()
await db.refresh(new_org)
return {"organization_id": new_org.id, "status": new_org.status}
@router.get("/my", response_model=List[CorpOnboardResponse])
async def get_my_organizations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
""" A bejelentkezett felhasználóhoz tartozó összes szervezet listázása. """
stmt = (
select(Organization)
.join(OrganizationMember)
.where(OrganizationMember.user_id == current_user.id)
)
result = await db.execute(stmt)
orgs = result.scalars().all()
return [{"organization_id": o.id, "status": o.status} for o in orgs]

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,24 @@
# backend/app/api/v1/endpoints/search.py
from fastapi import APIRouter, Depends
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.models.organization import Organization # JAVÍTVA
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)):
# PostGIS alapú keresés a data.branches táblában (a régi locations helyett)
query = text("""
SELECT o.id, o.name, b.city,
ST_Distance(b.location, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography) / 1000 as distance
FROM data.organizations o
JOIN data.branches b ON o.id = b.organization_id
WHERE o.is_active = True AND b.is_active = True
AND ST_DWithin(b.location, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography, :r * 1000)
ORDER BY distance ASC
""")
result = await db.execute(query, {"lat": lat, "lng": lng, "r": radius})
return {"results": [dict(row._mapping) for row in result.fetchall()]}

View File

@@ -0,0 +1,58 @@
from fastapi import APIRouter, Depends, Form, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, text
from typing import List, Optional
from app.db.session import get_db
from app.services.gamification_service import GamificationService
from app.models.service import ServiceProfile, ExpertiseTag, ServiceExpertise
router = APIRouter()
# --- 🎯 SZERVIZ VADÁSZAT (Service Hunt) ---
@router.post("/hunt")
async def register_service_hunt(
name: str = Form(...),
lat: float = Form(...),
lng: float = Form(...),
db: AsyncSession = Depends(get_db)
):
""" Új szerviz-jelölt rögzítése a staging táblába jutalompontért. """
# Új szerviz-jelölt rögzítése
await db.execute(text("""
INSERT INTO data.service_staging (name, fingerprint, status, raw_data)
VALUES (:n, :f, 'pending', jsonb_build_object('lat', :lat, 'lng', :lng))
"""), {"n": name, "f": f"{name}-{lat}-{lng}", "lat": lat, "lng": lng})
# MB 2.0 Gamification: 50 pont a felfedezésért
# TODO: A 1-es ID helyett a bejelentkezett felhasználót kell használni (current_user.id)
await GamificationService.award_points(db, 1, 50, f"Service Hunt: {name}")
await db.commit()
return {"status": "success", "message": "Discovery registered and points awarded."}
# --- 🔍 SZERVIZ KERESŐ (Service Search) ---
@router.get("/search")
async def search_services(
expertise_key: Optional[str] = Query(None, description="Szakmai címke (pl. brake_service)"),
city: Optional[str] = Query(None, description="Város szűrés"),
min_confidence: int = Query(0, description="Minimum hitelességi szint (0-2)"),
db: AsyncSession = Depends(get_db)
):
""" Szakmai szempontú keresőmotor, ami a validált címkék alapján szűr. """
# Alap lekérdezés: Szervizek, akiknek van szakértelmük
query = select(ServiceProfile).join(ServiceProfile.expertises).join(ServiceExpertise.tag)
filters = []
if expertise_key:
filters.append(ExpertiseTag.key == expertise_key)
if city:
filters.append(ServiceProfile.city.ilike(f"%{city}%"))
if min_confidence > 0:
filters.append(ServiceExpertise.confidence_level >= min_confidence)
if filters:
query = query.where(and_(*filters))
result = await db.execute(query.distinct())
services = result.scalars().all()
return services

View File

@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
# ITT A JAVÍTÁS: A példányt importáljuk, nem a régi függvényeket
from app.services.social_service import social_service
router = APIRouter()
@router.get("/leaderboard")
async def read_leaderboard(limit: int = 10, db: AsyncSession = Depends(get_db)):
return await social_service.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 social_service.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