admin frontend elkezdése, járművek tisztázása, frontend fejleszts
This commit is contained in:
@@ -4,7 +4,8 @@ from app.api.v1.endpoints import (
|
||||
auth, catalog, assets, organizations, documents,
|
||||
services, admin, expenses, evidence, social, security,
|
||||
billing, finance_admin, analytics, vehicles, system_parameters,
|
||||
gamification, translations, users, reports, dictionaries
|
||||
gamification, translations, users, reports, dictionaries,
|
||||
admin_packages, admin_services
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -29,4 +30,6 @@ api_router.include_router(gamification.router, prefix="/gamification", tags=["Ga
|
||||
api_router.include_router(translations.router, prefix="/translations", tags=["i18n"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["Users"])
|
||||
api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
|
||||
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
|
||||
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
|
||||
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
|
||||
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
|
||||
@@ -1,12 +1,14 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body, BackgroundTasks
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body, BackgroundTasks, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, text, delete
|
||||
from sqlalchemy import select, func, text, delete, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Any, Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.api import deps
|
||||
from app.models.identity import User, UserRole # JAVÍTVA: Központi import
|
||||
from app.models.identity import User, UserRole, Person # JAVÍTVA: Központi import
|
||||
from app.models.identity.address import Address
|
||||
from app.models.system import SystemParameter, ParameterScope
|
||||
from app.services.system_service import system_service
|
||||
# JAVÍTVA: Security audit modellek
|
||||
@@ -16,7 +18,6 @@ from app.models import PendingAction, ActionStatus
|
||||
|
||||
from app.services.security_service import security_service
|
||||
from app.services.translation_service import TranslationService
|
||||
from app.services.odometer_service import OdometerService
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional as Opt
|
||||
|
||||
@@ -29,19 +30,10 @@ class ConfigUpdate(BaseModel):
|
||||
|
||||
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)
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
):
|
||||
stats = {}
|
||||
|
||||
@@ -71,7 +63,7 @@ async def get_system_health(
|
||||
@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)
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
):
|
||||
stmt = select(PendingAction).where(PendingAction.status == ActionStatus.pending)
|
||||
result = await db.execute(stmt)
|
||||
@@ -81,7 +73,7 @@ async def list_pending_actions(
|
||||
async def approve_action(
|
||||
action_id: int,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
):
|
||||
try:
|
||||
await security_service.approve_action(db, admin.id, action_id)
|
||||
@@ -91,8 +83,8 @@ async def approve_action(
|
||||
|
||||
@router.get("/parameters", tags=["Dynamic Configuration"])
|
||||
async def list_all_parameters(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
):
|
||||
result = await db.execute(select(SystemParameter))
|
||||
return result.scalars().all()
|
||||
@@ -101,7 +93,7 @@ async def list_all_parameters(
|
||||
async def set_parameter(
|
||||
config: ConfigUpdate,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
):
|
||||
query = text("""
|
||||
INSERT INTO system.system_parameters (key, value, scope_level, scope_id, category, last_modified_by)
|
||||
@@ -132,7 +124,7 @@ async def get_scoped_parameter(
|
||||
region_id: Optional[str] = None,
|
||||
country_code: Optional[str] = None,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
):
|
||||
"""
|
||||
Hierarchikus paraméterlekérdezés a következő prioritással:
|
||||
@@ -151,92 +143,12 @@ async def get_scoped_parameter(
|
||||
@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)
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
):
|
||||
await TranslationService.export_to_json(db)
|
||||
return {"message": "JSON fájlok frissítve."}
|
||||
|
||||
|
||||
# ==================== SMART ODOMETER ADMIN API ====================
|
||||
|
||||
class OdometerStatsResponse(BaseModel):
|
||||
vehicle_id: int
|
||||
last_recorded_odometer: int
|
||||
last_recorded_date: datetime
|
||||
daily_avg_distance: float
|
||||
estimated_current_odometer: float
|
||||
confidence_score: float
|
||||
manual_override_avg: Opt[float]
|
||||
is_confidence_high: bool = Field(..., description="True ha confidence_score >= threshold")
|
||||
|
||||
class ManualOverrideRequest(BaseModel):
|
||||
daily_avg: Opt[float] = Field(None, description="Napi átlagos kilométer (km/nap). Ha null, törli a manuális beállítást.")
|
||||
|
||||
@router.get("/odometer/{vehicle_id}", tags=["Smart Odometer"])
|
||||
async def get_odometer_stats(
|
||||
vehicle_id: int,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
"""
|
||||
Jármű kilométeróra statisztikáinak lekérése.
|
||||
|
||||
A rendszer automatikusan frissíti a statisztikákat, ha szükséges.
|
||||
"""
|
||||
# Frissítjük a statisztikákat
|
||||
odometer_state = await OdometerService.update_vehicle_stats(db, vehicle_id)
|
||||
if not odometer_state:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Jármű nem található ID: {vehicle_id}"
|
||||
)
|
||||
|
||||
# Confidence threshold lekérése
|
||||
confidence_threshold = await OdometerService.get_system_param(
|
||||
db, 'ODOMETER_CONFIDENCE_THRESHOLD', 0.5
|
||||
)
|
||||
|
||||
return OdometerStatsResponse(
|
||||
vehicle_id=odometer_state.vehicle_id,
|
||||
last_recorded_odometer=odometer_state.last_recorded_odometer,
|
||||
last_recorded_date=odometer_state.last_recorded_date,
|
||||
daily_avg_distance=float(odometer_state.daily_avg_distance),
|
||||
estimated_current_odometer=float(odometer_state.estimated_current_odometer),
|
||||
confidence_score=odometer_state.confidence_score,
|
||||
manual_override_avg=float(odometer_state.manual_override_avg) if odometer_state.manual_override_avg else None,
|
||||
is_confidence_high=odometer_state.confidence_score >= confidence_threshold
|
||||
)
|
||||
|
||||
@router.patch("/odometer/{vehicle_id}", tags=["Smart Odometer"])
|
||||
async def set_odometer_manual_override(
|
||||
vehicle_id: int,
|
||||
request: ManualOverrideRequest,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
"""
|
||||
Adminisztrátori manuális átlag beállítása a kilométeróra becsléshez.
|
||||
|
||||
Ha a user csal vagy hibás az adat, az admin ezzel felülírhatja az automatikus számítást.
|
||||
"""
|
||||
odometer_state = await OdometerService.set_manual_override(
|
||||
db, vehicle_id, request.daily_avg
|
||||
)
|
||||
|
||||
if not odometer_state:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Jármű nem található ID: {vehicle_id}"
|
||||
)
|
||||
|
||||
action = "beállítva" if request.daily_avg is not None else "törölve"
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Manuális átlag {action}: {request.daily_avg} km/nap",
|
||||
"vehicle_id": vehicle_id,
|
||||
"manual_override_avg": odometer_state.manual_override_avg
|
||||
}
|
||||
|
||||
@router.get("/ping", tags=["Admin Test"])
|
||||
async def admin_ping(
|
||||
current_user: User = Depends(deps.get_current_admin)
|
||||
@@ -551,4 +463,275 @@ async def apply_gamification_penalty(
|
||||
"penalty_level": penalty.penalty_level,
|
||||
"new_level": new_level,
|
||||
"reason": penalty.reason
|
||||
}
|
||||
|
||||
|
||||
# ==================== USER MANAGEMENT (EPIC 10: Admin Frontend) ====================
|
||||
|
||||
|
||||
class BulkActionRequest(BaseModel):
|
||||
"""Csoportos művelet kérés sémája."""
|
||||
user_ids: List[int] = Field(..., min_length=1, description="Felhasználók ID listája")
|
||||
action: str = Field(..., pattern="^(ban|unban|soft_delete|restore|hard_delete)$", description="Végrehajtandó művelet")
|
||||
|
||||
|
||||
@router.get("/users", tags=["User Management"])
|
||||
async def list_users(
|
||||
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
|
||||
limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
|
||||
search_term: Optional[str] = Query(None, description="Keresőszó a célzott kereséshez"),
|
||||
search_category: str = Query('all', description="Keresési kategória: email, name, phone, id, address, all"),
|
||||
role: Optional[str] = Query(None, description="Szerepkör szűrés (pl. admin, user)"),
|
||||
is_active: Optional[bool] = Query(None, description="Aktív státusz szűrés"),
|
||||
is_deleted: Optional[bool] = Query(None, description="Törölt státusz szűrés"),
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_admin: User = Depends(deps.get_current_admin),
|
||||
):
|
||||
"""
|
||||
Felhasználók listázása lapozással és célzott kereséssel.
|
||||
|
||||
Támogatott keresési kategóriák (search_category):
|
||||
- **email**: Keresés e-mail címben (ILIKE)
|
||||
- **name**: Keresés Person vezeték- VAGY keresztnévben (ILIKE)
|
||||
- **phone**: Keresés Person telefonszámában (ILIKE)
|
||||
- **id**: Pontos egyezés User ID alapján
|
||||
- **address**: Keresés címmezőkben (postal_code, city, street, house_number)
|
||||
|
||||
NINCS automatikus debounce keresés! A keresés csak explicit paraméterrel indul.
|
||||
"""
|
||||
# Alap lekérdezés: User + Person (outerjoin) + Address (outerjoin)
|
||||
# KRITIKUS: selectinload helyett explicit outerjoin + contains_eager kell,
|
||||
# mert a selectinload KÜLÖN query-ben tölti be a kapcsolódó adatokat,
|
||||
# így a WHERE feltételek a Person/Address oszlopokra NEM működnének!
|
||||
from sqlalchemy.orm import contains_eager, joinedload
|
||||
from app.models.identity.address import GeoPostalCode
|
||||
|
||||
query = (
|
||||
select(User)
|
||||
.outerjoin(Person, User.person_id == Person.id)
|
||||
.outerjoin(Address, Person.address_id == Address.id)
|
||||
.outerjoin(GeoPostalCode, Address.postal_code_id == GeoPostalCode.id)
|
||||
.options(
|
||||
contains_eager(User.person),
|
||||
contains_eager(User.person, Person.address),
|
||||
)
|
||||
)
|
||||
|
||||
# Célzott keresés - csak akkor alkalmazzuk, ha van search_term
|
||||
if search_term:
|
||||
if search_category == 'all':
|
||||
query = query.where(
|
||||
or_(
|
||||
User.email.ilike(f"%{search_term}%"),
|
||||
Person.first_name.ilike(f"%{search_term}%"),
|
||||
Person.last_name.ilike(f"%{search_term}%"),
|
||||
Person.phone.ilike(f"%{search_term}%")
|
||||
)
|
||||
)
|
||||
elif search_category == 'email':
|
||||
query = query.where(User.email.ilike(f"%{search_term}%"))
|
||||
elif search_category == 'name':
|
||||
query = query.where(
|
||||
or_(
|
||||
Person.first_name.ilike(f"%{search_term}%"),
|
||||
Person.last_name.ilike(f"%{search_term}%")
|
||||
)
|
||||
)
|
||||
elif search_category == 'phone':
|
||||
query = query.where(Person.phone.ilike(f"%{search_term}%"))
|
||||
elif search_category == 'id':
|
||||
try:
|
||||
user_id = int(search_term)
|
||||
query = query.where(User.id == user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Az 'id' keresési kategóriához numerikus érték szükséges."
|
||||
)
|
||||
elif search_category == 'address':
|
||||
# FIGYELEM: Address.zip és Address.city PYTHON property-k (a GeoPostalCode kapcsolatból)
|
||||
# Az SQL lekérdezésben a GeoPostalCode tábla oszlopait kell használni!
|
||||
query = query.where(
|
||||
or_(
|
||||
GeoPostalCode.zip_code.ilike(f"%{search_term}%"),
|
||||
GeoPostalCode.city.ilike(f"%{search_term}%"),
|
||||
Address.street_name.ilike(f"%{search_term}%"),
|
||||
Address.house_number.ilike(f"%{search_term}%")
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Érvénytelen keresési kategória: {search_category}. Érvényes: email, name, phone, id, address, all"
|
||||
)
|
||||
|
||||
if role:
|
||||
try:
|
||||
role_enum = UserRole(role)
|
||||
query = query.where(User.role == role_enum)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Érvénytelen szerepkör: {role}. Érvényes értékek: {[r.value for r in UserRole]}"
|
||||
)
|
||||
if is_active is not None:
|
||||
query = query.where(User.is_active == is_active)
|
||||
if is_deleted is not None:
|
||||
query = query.where(User.is_deleted == is_deleted)
|
||||
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(User.id.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
# Unique users (outerjoin may duplicate)
|
||||
seen_ids = set()
|
||||
unique_users = []
|
||||
for row in result.unique().scalars().all():
|
||||
if row.id not in seen_ids:
|
||||
seen_ids.add(row.id)
|
||||
unique_users.append(row)
|
||||
|
||||
user_list = []
|
||||
for u in unique_users:
|
||||
person = u.person
|
||||
address = person.address if person else None
|
||||
# Cím összefűzése
|
||||
address_str = None
|
||||
if address:
|
||||
parts = []
|
||||
if address.zip:
|
||||
parts.append(str(address.zip))
|
||||
if address.city:
|
||||
parts.append(str(address.city))
|
||||
if address.street_name:
|
||||
parts.append(str(address.street_name))
|
||||
if address.house_number:
|
||||
parts.append(str(address.house_number))
|
||||
address_str = ', '.join(parts) if parts else None
|
||||
|
||||
user_list.append({
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"role": u.role.value if hasattr(u.role, "value") else str(u.role),
|
||||
"is_active": u.is_active,
|
||||
"is_deleted": u.is_deleted,
|
||||
"subscription_plan": u.subscription_plan,
|
||||
"region_code": u.region_code,
|
||||
"preferred_language": u.preferred_language,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
"deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
|
||||
# Person adatok
|
||||
"first_name": person.first_name if person else None,
|
||||
"last_name": person.last_name if person else None,
|
||||
"phone": person.phone if person else None,
|
||||
# Címadatok
|
||||
"postal_code": str(address.zip) if address and address.zip else None,
|
||||
"city": address.city if address and address.city else None,
|
||||
"street": address.street_name if address and address.street_name else None,
|
||||
"house_number": address.house_number if address and address.house_number else None,
|
||||
"address": address_str,
|
||||
})
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"users": user_list,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/bulk-action", tags=["User Management"])
|
||||
async def bulk_user_action(
|
||||
request: BulkActionRequest,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_admin: User = Depends(deps.get_current_admin),
|
||||
):
|
||||
"""
|
||||
Csoportos műveletek felhasználókon.
|
||||
|
||||
Támogatott akciók:
|
||||
- **ban**: Felhasználó tiltása (is_active=False). Rang >= 50 (Moderator) szükséges.
|
||||
- **unban**: Tiltás feloldása (is_active=True). Rang >= 90 (Admin) szükséges.
|
||||
- **soft_delete**: Lágy törlés (is_deleted=True, deleted_at=now). Rang >= 90 (Admin) szükséges.
|
||||
- **restore**: Lágy törlés visszaállítása (is_deleted=False, deleted_at=None). Rang >= 90 (Admin) szükséges.
|
||||
- **hard_delete**: Végleges törlés az adatbázisból. Rang >= 100 (Superadmin) szükséges!
|
||||
"""
|
||||
role_key = current_admin.role.value.upper() if hasattr(current_admin.role, "value") else str(current_admin.role).upper()
|
||||
from app.core.security import DEFAULT_RANK_MAP
|
||||
admin_rank = DEFAULT_RANK_MAP.get(role_key, 0)
|
||||
|
||||
if request.action == "hard_delete":
|
||||
if admin_rank < 100:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Végleges törléshez Superadmin jogosultság szükséges (rank >= 100)."
|
||||
)
|
||||
elif request.action in ("soft_delete", "restore", "unban"):
|
||||
if admin_rank < 90:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Ehhez a művelethez Admin jogosultság szükséges (rank >= 90)."
|
||||
)
|
||||
elif request.action in ("ban",):
|
||||
if admin_rank < 50:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Tiltáshoz Moderator jogosultság szükséges (rank >= 50)."
|
||||
)
|
||||
|
||||
stmt = select(User).where(User.id.in_(request.user_ids))
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
|
||||
if not users:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Egy felhasználó sem található a megadott ID-kkel."
|
||||
)
|
||||
|
||||
found_ids = {u.id for u in users}
|
||||
missing_ids = [uid for uid in request.user_ids if uid not in found_ids]
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
affected_count = 0
|
||||
|
||||
for user in users:
|
||||
if user.role == UserRole.superadmin and user.id != current_admin.id:
|
||||
continue
|
||||
|
||||
if request.action == "ban":
|
||||
user.is_active = False
|
||||
elif request.action == "unban":
|
||||
user.is_active = True
|
||||
elif request.action == "soft_delete":
|
||||
user.is_deleted = True
|
||||
user.deleted_at = now
|
||||
elif request.action == "restore":
|
||||
user.is_deleted = False
|
||||
user.deleted_at = None
|
||||
elif request.action == "hard_delete":
|
||||
await db.delete(user)
|
||||
|
||||
affected_count += 1
|
||||
|
||||
audit_log = SecurityAuditLog(
|
||||
user_id=current_admin.id,
|
||||
action=f"bulk_{request.action}",
|
||||
details=f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}",
|
||||
is_critical=(request.action in ("hard_delete", "ban")),
|
||||
ip_address="admin_api"
|
||||
)
|
||||
db.add(audit_log)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"action": request.action,
|
||||
"affected_count": affected_count,
|
||||
"total_requested": len(request.user_ids),
|
||||
"missing_ids": missing_ids if missing_ids else None,
|
||||
"message": f"{request.action} művelet végrehajtva {affected_count} felhasználón."
|
||||
}
|
||||
319
backend/app/api/v1/endpoints/admin_packages.py
Normal file
319
backend/app/api/v1/endpoints/admin_packages.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
📦 Admin Package Manager API
|
||||
|
||||
SubscriptionTier csomagok adminisztrációs végpontjai.
|
||||
Minden végpont védve van a Depends(get_current_admin) dependency-vel.
|
||||
|
||||
Végpontok:
|
||||
GET /admin/packages/ — Listázza az összes csomagot
|
||||
POST /admin/packages/ — Új csomag létrehozása
|
||||
PATCH /admin/packages/{id} — Csomag frissítése (pl. rules JSONB, név)
|
||||
DELETE /admin/packages/{id} — Csomag logikai törlése (lifecycle.is_public=false)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Date, cast, func, select, update, delete as sa_delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api import deps
|
||||
from app.db.session import get_db
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
from app.models.identity import User
|
||||
from app.schemas.subscription import (
|
||||
SubscriptionTierCreate,
|
||||
SubscriptionTierListResponse,
|
||||
SubscriptionTierResponse,
|
||||
SubscriptionTierUpdate,
|
||||
SubscriptionRulesModel,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("admin-packages")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET / — Listázza az összes SubscriptionTier rekordot
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=SubscriptionTierListResponse,
|
||||
summary="Csomagok listázása",
|
||||
description="Visszaadja az összes előfizetési csomagot (SubscriptionTier).",
|
||||
)
|
||||
async def list_packages(
|
||||
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
|
||||
limit: int = Query(100, ge=1, le=500, description="Maximum visszaadott rekordok száma"),
|
||||
include_hidden: bool = Query(
|
||||
False,
|
||||
description="Ha True, a nem publikus (lifecycle.is_public=false) csomagokat is visszaadja",
|
||||
),
|
||||
type_filter: Optional[str] = Query(
|
||||
None,
|
||||
description="Szűrés típus szerint: 'private' vagy 'corporate' (rules->>'type')",
|
||||
),
|
||||
date_from: Optional[str] = Query(
|
||||
None,
|
||||
description="Szűrés kezdő dátumtól (available_from a lifecycle-ben, ISO formátum: YYYY-MM-DD)",
|
||||
),
|
||||
date_until: Optional[str] = Query(
|
||||
None,
|
||||
description="Szűrés záró dátumig (available_until a lifecycle-ben, ISO formátum: YYYY-MM-DD)",
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
) -> SubscriptionTierListResponse:
|
||||
"""
|
||||
Összes előfizetési csomag listázása.
|
||||
|
||||
Alapértelmezetten csak a publikus csomagokat adja vissza
|
||||
(ahol a rules->'lifecycle'->>'is_public' != 'false').
|
||||
Az `include_hidden=True` paraméterrel az összes rekord lekérhető.
|
||||
|
||||
Szűrési lehetőségek:
|
||||
- `type_filter`: 'private' vagy 'corporate' a rules->>'type' mező alapján
|
||||
- `date_from`: Csak azokat a csomagokat adja vissza, amelyek available_from >= date_from
|
||||
- `date_until`: Csak azokat a csomagokat adja vissza, amelyek available_until <= date_until
|
||||
"""
|
||||
stmt = select(SubscriptionTier).order_by(SubscriptionTier.id)
|
||||
|
||||
if not include_hidden:
|
||||
# Csak a publikus csomagok (ahol nincs lifecycle.is_public=false)
|
||||
stmt = stmt.where(
|
||||
~SubscriptionTier.rules["lifecycle"]["is_public"].as_string().in_(["false"])
|
||||
)
|
||||
|
||||
# ── Típus szűrő (rules->>'type') ──
|
||||
if type_filter:
|
||||
type_filter_lower = type_filter.strip().lower()
|
||||
if type_filter_lower in ("private", "corporate"):
|
||||
stmt = stmt.where(
|
||||
SubscriptionTier.rules["type"].as_string() == type_filter_lower
|
||||
)
|
||||
|
||||
# ── Dátum szűrők (lifecycle JSONB mezők) ──
|
||||
# A JSONB mezőkben tárolt dátumokat cast(Date)-té alakítjuk a helyes összehasonlításhoz
|
||||
if date_from:
|
||||
stmt = stmt.where(
|
||||
cast(SubscriptionTier.rules["lifecycle"]["available_from"].as_string(), Date) >= date_from
|
||||
)
|
||||
if date_until:
|
||||
stmt = stmt.where(
|
||||
cast(SubscriptionTier.rules["lifecycle"]["available_until"].as_string(), Date) <= date_until
|
||||
)
|
||||
|
||||
# Teljes számolás
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Lapozás
|
||||
stmt = stmt.offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
tiers = result.scalars().all()
|
||||
|
||||
return SubscriptionTierListResponse(
|
||||
total=total,
|
||||
tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST / — Új csomag létrehozása
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=SubscriptionTierResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Új csomag létrehozása",
|
||||
description="Létrehoz egy új előfizetési csomagot a megadott rules JSONB-vel.",
|
||||
)
|
||||
async def create_package(
|
||||
payload: SubscriptionTierCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
) -> SubscriptionTierResponse:
|
||||
"""
|
||||
Új előfizetési csomag létrehozása.
|
||||
|
||||
- A `name` mező egyedi kell legyen (unique constraint).
|
||||
- A `rules` mezőben a teljes SubscriptionRulesModel struktúrát kell megadni.
|
||||
- Alapértelmezetten a lifecycle.is_public = True lesz beállítva.
|
||||
"""
|
||||
# Ellenőrizzük, hogy létezik-e már ilyen nevű csomag
|
||||
existing = await db.execute(
|
||||
select(SubscriptionTier).where(SubscriptionTier.name == payload.name)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Már létezik csomag ezzel a névvel: '{payload.name}'",
|
||||
)
|
||||
|
||||
# Alapértelmezett lifecycle beállítás, ha nincs megadva
|
||||
rules_dict = payload.rules.model_dump()
|
||||
if rules_dict.get("lifecycle") is None:
|
||||
rules_dict["lifecycle"] = {"is_public": True}
|
||||
|
||||
tier = SubscriptionTier(
|
||||
name=payload.name,
|
||||
rules=rules_dict,
|
||||
is_custom=payload.is_custom,
|
||||
)
|
||||
db.add(tier)
|
||||
await db.commit()
|
||||
await db.refresh(tier)
|
||||
|
||||
logger.info(
|
||||
f"Admin {admin.id} ({admin.email}) created subscription tier: "
|
||||
f"name={tier.name}, id={tier.id}"
|
||||
)
|
||||
|
||||
return SubscriptionTierResponse.model_validate(tier)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PATCH /{tier_id} — Csomag frissítése
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{tier_id}",
|
||||
response_model=SubscriptionTierResponse,
|
||||
summary="Csomag frissítése",
|
||||
description="Frissíti egy meglévő csomag adatait (név, rules JSONB, is_custom).",
|
||||
)
|
||||
async def update_package(
|
||||
tier_id: int,
|
||||
payload: SubscriptionTierUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
) -> SubscriptionTierResponse:
|
||||
"""
|
||||
Meglévő előfizetési csomag részleges frissítése.
|
||||
|
||||
- Csak a megadott mezők frissülnek (PATCH szemantika).
|
||||
- A `rules` mezőben a teljes SubscriptionRulesModel-t át kell adni,
|
||||
ha módosítani szeretnéd (a JSONB oszlop teljes egészében lecserélődik).
|
||||
- A lifecycle.is_public false-ra állításával a csomag elrejthető.
|
||||
"""
|
||||
# Betöltjük a meglévő rekordot
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
|
||||
if not tier:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Csomag nem található ID-vel: {tier_id}",
|
||||
)
|
||||
|
||||
# Részleges frissítés
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if "name" in update_data and update_data["name"] is not None:
|
||||
# Ellenőrizzük az egyediséget (kivéve, ha ugyanaz a név)
|
||||
if update_data["name"] != tier.name:
|
||||
existing = await db.execute(
|
||||
select(SubscriptionTier).where(
|
||||
SubscriptionTier.name == update_data["name"],
|
||||
SubscriptionTier.id != tier_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Már létezik csomag ezzel a névvel: '{update_data['name']}'",
|
||||
)
|
||||
tier.name = update_data["name"]
|
||||
|
||||
if "rules" in update_data and update_data["rules"] is not None:
|
||||
# A meglévő rules-ból megtartjuk a lifecycle-t, ha nem adtak újat
|
||||
new_rules = update_data["rules"]
|
||||
# Biztosítjuk a JSON kompatibilis szerializációt (model_dump(mode='json') már megtörtént)
|
||||
if isinstance(new_rules, dict) and new_rules.get("lifecycle") is None and tier.rules.get("lifecycle"):
|
||||
new_rules["lifecycle"] = tier.rules["lifecycle"]
|
||||
tier.rules = new_rules
|
||||
|
||||
if "is_custom" in update_data and update_data["is_custom"] is not None:
|
||||
tier.is_custom = update_data["is_custom"]
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tier)
|
||||
|
||||
logger.info(
|
||||
f"Admin {admin.id} ({admin.email}) updated subscription tier: "
|
||||
f"id={tier.id}, name={tier.name}"
|
||||
)
|
||||
|
||||
return SubscriptionTierResponse.model_validate(tier)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DELETE /{tier_id} — Csomag logikai törlése (soft-delete)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{tier_id}",
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Csomag logikai törlése",
|
||||
description="Nem törli fizikailag, csak a lifecycle.is_public = false értékre állítja.",
|
||||
)
|
||||
async def delete_package(
|
||||
tier_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Csomag logikai törlése (soft-delete / kivezetés).
|
||||
|
||||
A rekord NEM kerül törlésre az adatbázisból!
|
||||
Ehelyett a `rules` JSONB `lifecycle.is_public` mezője `false`-ra áll,
|
||||
így a csomag eltűnik a frontend listákból, de a meglévő előfizetések
|
||||
továbbra is hivatkozhatnak rá.
|
||||
|
||||
Ha a csomag már nem publikus, akkor is 200 OK-val tér vissza
|
||||
(idempotens művelet).
|
||||
"""
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
|
||||
if not tier:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Csomag nem található ID-vel: {tier_id}",
|
||||
)
|
||||
|
||||
# Logikai törlés: lifecycle.is_public = false
|
||||
current_rules = dict(tier.rules) if tier.rules else {}
|
||||
if "lifecycle" not in current_rules:
|
||||
current_rules["lifecycle"] = {}
|
||||
current_rules["lifecycle"]["is_public"] = False
|
||||
tier.rules = current_rules
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tier)
|
||||
|
||||
logger.info(
|
||||
f"Admin {admin.id} ({admin.email}) deactivated subscription tier: "
|
||||
f"id={tier.id}, name={tier.name}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Csomag '{tier.name}' (ID: {tier.id}) inaktíválva.",
|
||||
"tier_id": tier.id,
|
||||
"is_public": False,
|
||||
}
|
||||
110
backend/app/api/v1/endpoints/admin_services.py
Normal file
110
backend/app/api/v1/endpoints/admin_services.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_services.py
|
||||
"""
|
||||
Admin Service Catalog CRUD végpontok.
|
||||
Prefix: /admin/services
|
||||
Védve: Depends(get_current_admin)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List
|
||||
|
||||
from app.api import deps
|
||||
from app.models.identity import User
|
||||
from app.models.core_logic import ServiceCatalog
|
||||
from app.schemas.service_catalog import (
|
||||
ServiceCatalogCreate,
|
||||
ServiceCatalogUpdate,
|
||||
ServiceCatalogResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=List[ServiceCatalogResponse], tags=["Admin Service Catalog"])
|
||||
async def list_service_catalog(
|
||||
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
|
||||
limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
|
||||
is_active: bool | None = Query(None, description="Szűrés aktív/inaktív státuszra"),
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
):
|
||||
"""
|
||||
Szolgáltatás katalógus bejegyzések listázása lapozással.
|
||||
"""
|
||||
query = select(ServiceCatalog)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(ServiceCatalog.is_active == is_active)
|
||||
|
||||
# Teljes darabszám
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(ServiceCatalog.id.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.post("", response_model=ServiceCatalogResponse, status_code=status.HTTP_201_CREATED, tags=["Admin Service Catalog"])
|
||||
async def create_service_catalog(
|
||||
data: ServiceCatalogCreate,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
):
|
||||
"""
|
||||
Új szolgáltatás katalógus bejegyzés létrehozása.
|
||||
"""
|
||||
# Ellenőrizzük, hogy a service_code már létezik-e
|
||||
existing = await db.execute(
|
||||
select(ServiceCatalog).where(ServiceCatalog.service_code == data.service_code)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Service code '{data.service_code}' already exists.",
|
||||
)
|
||||
|
||||
entry = ServiceCatalog(
|
||||
service_code=data.service_code,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
credit_cost=data.credit_cost,
|
||||
is_active=data.is_active,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.patch("/{service_id}", response_model=ServiceCatalogResponse, tags=["Admin Service Catalog"])
|
||||
async def update_service_catalog(
|
||||
service_id: int,
|
||||
data: ServiceCatalogUpdate,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
):
|
||||
"""
|
||||
Meglévő szolgáltatás katalógus bejegyzés módosítása.
|
||||
"""
|
||||
result = await db.execute(select(ServiceCatalog).where(ServiceCatalog.id == service_id))
|
||||
entry = result.scalar_one_or_none()
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Service catalog entry with ID {service_id} not found.",
|
||||
)
|
||||
|
||||
# Csak a megadott mezőket frissítjük
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(entry, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return entry
|
||||
@@ -18,6 +18,8 @@ from app.services.asset_service import AssetService
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
|
||||
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ArchiveVehicleRequest(BaseModel):
|
||||
"""Payload a jármű archiválásához (Soft Delete)."""
|
||||
@@ -26,8 +28,6 @@ class ArchiveVehicleRequest(BaseModel):
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/vehicles/quota-status")
|
||||
async def get_vehicle_quota_status(
|
||||
@@ -249,13 +249,36 @@ async def list_asset_costs(
|
||||
"""Tételes költséglista lapozással (Pagination)."""
|
||||
stmt = (
|
||||
select(AssetCost)
|
||||
.options(selectinload(AssetCost.category))
|
||||
.where(AssetCost.asset_id == asset_id)
|
||||
.order_by(desc(AssetCost.date))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
res = await db.execute(stmt)
|
||||
return res.scalars().all()
|
||||
costs = res.scalars().all()
|
||||
|
||||
# Map DB models to response, enriching with category info and data fields
|
||||
result = []
|
||||
for cost in costs:
|
||||
cost_dict = {
|
||||
"id": cost.id,
|
||||
"asset_id": cost.asset_id,
|
||||
"organization_id": cost.organization_id,
|
||||
"amount_net": cost.amount_net,
|
||||
"currency": cost.currency,
|
||||
"date": cost.date,
|
||||
"invoice_number": cost.invoice_number,
|
||||
"data": cost.data or {},
|
||||
"category_id": cost.category_id,
|
||||
"category_name": cost.category.name if cost.category else None,
|
||||
"category_code": cost.category.code if cost.category else None,
|
||||
"description": (cost.data or {}).get("description"),
|
||||
"mileage_at_cost": (cost.data or {}).get("mileage_at_cost"),
|
||||
}
|
||||
result.append(AssetCostResponse(**cost_dict))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
@@ -373,7 +396,6 @@ async def create_or_claim_vehicle(
|
||||
# első elérhető szervezetét a fleet.organization_members táblában
|
||||
if org_id is None:
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
from sqlalchemy import select
|
||||
member_stmt = (
|
||||
select(OrganizationMember.organization_id)
|
||||
.where(OrganizationMember.user_id == current_user.id)
|
||||
@@ -405,7 +427,6 @@ async def create_or_claim_vehicle(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Vehicle creation error: {e}")
|
||||
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű létrehozásakor")
|
||||
|
||||
@@ -487,7 +508,6 @@ async def update_vehicle(
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Vehicle update error: {e}")
|
||||
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű frissítésekor")
|
||||
|
||||
@@ -685,7 +705,6 @@ async def create_maintenance_record(
|
||||
)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Maintenance record creation error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -305,4 +305,45 @@ async def get_current_user_profile(
|
||||
from app.schemas.user import UserResponse
|
||||
from app.api.v1.endpoints.users import _build_user_response
|
||||
response_data = _build_user_response(current_user)
|
||||
return UserResponse.model_validate(response_data)
|
||||
return UserResponse.model_validate(response_data)
|
||||
|
||||
|
||||
# ── 30 NAPOS FIÓKVISSZAÁLLÍTÁS (RESTORE) ──
|
||||
|
||||
class RestoreRequestIn(BaseModel):
|
||||
email: EmailStr = Field(..., description="A törölt fiók email címe")
|
||||
|
||||
class RestoreVerifyIn(BaseModel):
|
||||
email: EmailStr = Field(..., description="A törölt fiók email címe")
|
||||
code: str = Field(..., min_length=6, max_length=6, description="6-jegyű visszaállítási kód")
|
||||
|
||||
@router.post("/restore/request")
|
||||
async def restore_account_request(
|
||||
request: RestoreRequestIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Fiók visszaállítási kód kérése.
|
||||
Ha a fiók 30 napon belül lett törölve, 6-jegyű OTP kódot küld.
|
||||
Ha 30 napon túl van, új regisztrációt engedélyez.
|
||||
"""
|
||||
result = await AuthService.request_account_restore(db, request.email)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/restore/verify")
|
||||
async def restore_account_verify(
|
||||
request: RestoreVerifyIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Fiók visszaállítási kód ellenőrzése.
|
||||
Sikeres ellenőrzés után visszaállítja a fiókot (is_active=True, is_deleted=False, deleted_at=None).
|
||||
"""
|
||||
result = await AuthService.verify_account_restore(db, request.email, request.code)
|
||||
if result.get("status") != "success":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.get("message", "Érvénytelen vagy lejárt kód.")
|
||||
)
|
||||
return result
|
||||
@@ -100,4 +100,67 @@ async def list_body_types(
|
||||
"name_hu": r.name_hu,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# NEW: Cascading Catalog Endpoints for Vehicle Wizard (Drill-down UI)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/brands", response_model=List[str])
|
||||
async def list_catalog_brands(
|
||||
vehicle_class: Optional[str] = Query(None, description="Szűrés járműosztályra (pl. personal, motorcycle)"),
|
||||
q: Optional[str] = Query(None, description="Keresési szöveg (autocomplete)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_user)
|
||||
):
|
||||
"""Márkák listája a vehicle_model_definitions táblából, autocomplete-hez.
|
||||
GET /catalog/brands?class=personal&q=bmw
|
||||
"""
|
||||
return await AssetService.get_catalog_brands(db, vehicle_class, q)
|
||||
|
||||
|
||||
@router.get("/brands/{brand}/models")
|
||||
async def list_catalog_models(
|
||||
brand: str,
|
||||
vehicle_class: Optional[str] = Query(None, description="Szűrés járműosztályra"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_user)
|
||||
):
|
||||
"""Adott márkához tartozó modellek listája.
|
||||
GET /catalog/brands/BMW/models
|
||||
"""
|
||||
if not brand or brand.strip() == "":
|
||||
return []
|
||||
return await AssetService.get_catalog_models(db, brand, vehicle_class)
|
||||
|
||||
|
||||
@router.get("/brands/{brand}/models/{model}/years")
|
||||
async def list_catalog_years(
|
||||
brand: str,
|
||||
model: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_user)
|
||||
):
|
||||
"""Adott márkához és modellhez tartozó elérhető évjáratok.
|
||||
GET /catalog/brands/BMW/models/X5/years
|
||||
"""
|
||||
if not brand or not model or brand.strip() == "" or model.strip() == "":
|
||||
return []
|
||||
return await AssetService.get_catalog_years(db, brand, model)
|
||||
|
||||
|
||||
@router.get("/brands/{brand}/models/{model}/trims")
|
||||
async def list_catalog_trims(
|
||||
brand: str,
|
||||
model: str,
|
||||
year: Optional[int] = Query(None, description="Szűrés évjáratra"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_user)
|
||||
):
|
||||
"""Adott márkához, modellhez és opcionális évjárathoz tartozó felszereltségek/motorváltozatok.
|
||||
GET /catalog/brands/BMW/models/X5/trims?year=2023
|
||||
"""
|
||||
if not brand or not model or brand.strip() == "" or model.strip() == "":
|
||||
return []
|
||||
return await AssetService.get_catalog_trims(db, brand, model, year)
|
||||
@@ -90,14 +90,19 @@ async def create_expense(
|
||||
asset_id=expense.asset_id,
|
||||
organization_id=organization_id,
|
||||
category_id=expense.category_id,
|
||||
amount_net=expense.amount_local,
|
||||
currency=expense.currency_local,
|
||||
amount_net=expense.amount_net,
|
||||
currency=expense.currency,
|
||||
date=expense.date,
|
||||
invoice_number=data.get("invoice_number"),
|
||||
data=data
|
||||
)
|
||||
|
||||
db.add(new_cost)
|
||||
|
||||
# Update Asset.current_mileage if mileage_at_cost is higher
|
||||
if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0):
|
||||
asset.current_mileage = expense.mileage_at_cost
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_cost)
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import os
|
||||
import re
|
||||
import uuid
|
||||
import hashlib
|
||||
import random
|
||||
import logging
|
||||
from typing import List
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -13,9 +14,11 @@ 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, OrganizationUpdate, OrganizationResponse
|
||||
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch
|
||||
from app.models.identity import User # JAVÍTVA: Központi Identity modell
|
||||
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole
|
||||
from app.models.identity import User, OneTimePassword # JAVÍTVA: Központi Identity modell
|
||||
from app.core.config import settings
|
||||
from app.services.security_service import security_service
|
||||
from app.models import LogSeverity
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -275,6 +278,79 @@ async def update_organization_visual_settings(
|
||||
)
|
||||
|
||||
return OrganizationResponse.model_validate(org)
|
||||
|
||||
|
||||
@router.patch("/{org_id}", response_model=OrganizationResponse)
|
||||
async def update_organization(
|
||||
org_id: int,
|
||||
update_data: OrganizationUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Szervezet adatainak részleges frissítése (általános PATCH).
|
||||
|
||||
Támogatja a cégadatok (name, full_name, tax_number, display_name),
|
||||
cím adatok (address_zip, address_city, address_street_name, stb.),
|
||||
valamint a visual_settings, language, default_currency mezők frissítését.
|
||||
|
||||
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
||||
"""
|
||||
# 1. Jogosultság ellenőrzése
|
||||
stmt_member = select(OrganizationMember).where(
|
||||
(OrganizationMember.organization_id == org_id) &
|
||||
(OrganizationMember.user_id == current_user.id) &
|
||||
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
|
||||
)
|
||||
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nincs jogosultságod a szervezet adatainak módosításához (csak OWNER/ADMIN)."
|
||||
)
|
||||
|
||||
# 2. Szervezet lekérése
|
||||
stmt_org = select(Organization).where(
|
||||
Organization.id == org_id,
|
||||
Organization.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt_org)
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Szervezet nem található."
|
||||
)
|
||||
|
||||
# 3. JSONB merge visual_settings esetén
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
|
||||
incoming_vs = update_dict["visual_settings"]
|
||||
current_vs = dict(org.visual_settings) if org.visual_settings else {}
|
||||
current_vs.update(incoming_vs)
|
||||
org.visual_settings = current_vs
|
||||
del update_dict["visual_settings"]
|
||||
|
||||
# 4. Többi mező frissítése
|
||||
for field, value in update_dict.items():
|
||||
if hasattr(org, field) and value is not None:
|
||||
setattr(org, field, value)
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(org)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Hiba a szervezet frissítésekor (org_id={org_id}): {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Adatbázis hiba: {str(e)}"
|
||||
)
|
||||
|
||||
return OrganizationResponse.model_validate(org)
|
||||
|
||||
|
||||
# --- B2B MEGHÍVÓ LOGIKA ---
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
@@ -393,9 +469,274 @@ async def accept_invitation(
|
||||
status="active"
|
||||
)
|
||||
db.add(new_member)
|
||||
token_rec.is_used = True
|
||||
await db.commit()
|
||||
|
||||
token_rec.is_used = True
|
||||
await db.commit()
|
||||
return {"status": "success", "message": "Meghívó sikeresen elfogadva."}
|
||||
|
||||
return {"status": "success", "message": "Meghívó sikeresen elfogadva."}
|
||||
|
||||
|
||||
# ── CÉG CSATLAKOZÁS ÉS ÁRVA CÉG ÁTVÉTEL ──
|
||||
|
||||
from app.models.identity import OneTimePassword
|
||||
import random
|
||||
|
||||
class JoinRequestIn(BaseModel):
|
||||
"""Csatlakozási kérelem egy céghez, amelynek van aktív adminja."""
|
||||
message: Optional[str] = Field(None, description="Üzenet az adminnak")
|
||||
|
||||
class ClaimRequestIn(BaseModel):
|
||||
"""Árva cég átvételi kérelem - email ellenőrzés."""
|
||||
email: EmailStr = Field(..., description="A cég eredeti email címe")
|
||||
|
||||
class ClaimVerifyIn(BaseModel):
|
||||
"""Árva cég átvételi kérelem - OTP kód ellenőrzés."""
|
||||
email: EmailStr = Field(..., description="A cég eredeti email címe")
|
||||
code: str = Field(..., min_length=6, max_length=6, description="6-jegyű megerősítő kód")
|
||||
|
||||
|
||||
@router.post("/{org_id}/join-request")
|
||||
async def request_join_organization(
|
||||
org_id: int,
|
||||
request: JoinRequestIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Csatlakozási kérelem egy szervezethez, amelynek van aktív adminja.
|
||||
A kérelem 7 napig él, amíg az admin jóvá nem hagyja.
|
||||
"""
|
||||
# Ellenőrizzük, hogy létezik-e a szervezet
|
||||
stmt = select(Organization).where(
|
||||
Organization.id == org_id,
|
||||
Organization.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Szervezet nem található.")
|
||||
|
||||
# Ellenőrizzük, hogy van-e aktív admin
|
||||
stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN])
|
||||
).join(User, OrganizationMember.user_id == User.id).where(User.is_active == True, User.is_deleted == False)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
active_admins = result.scalars().all()
|
||||
|
||||
if not active_admins:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A szervezetnek nincs aktív adminja. Használja a /claim/request végpontot."
|
||||
)
|
||||
|
||||
# Ellenőrizzük, hogy a felhasználó már nem tag-e
|
||||
stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Már tagja vagy ennek a szervezetnek.")
|
||||
|
||||
# Létrehozzuk a pending tagot
|
||||
new_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
user_id=current_user.id,
|
||||
role=OrgUserRole.DRIVER,
|
||||
status="pending"
|
||||
)
|
||||
db.add(new_member)
|
||||
|
||||
# Naplózás
|
||||
await security_service.log_event(
|
||||
db, user_id=current_user.id, action="ORG_JOIN_REQUEST",
|
||||
severity=LogSeverity.info, target_type="Organization", target_id=str(org_id),
|
||||
new_data={"message": request.message or ""}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "pending",
|
||||
"message": "Csatlakozási kérelmed rögzítettük. Az admin jóváhagyására vársz."
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{org_id}/claim/request")
|
||||
async def claim_orphaned_organization_request(
|
||||
org_id: int,
|
||||
request: ClaimRequestIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Árva cég átvételi kérelem indítása.
|
||||
Ellenőrzi, hogy a megadott email cím egyezik-e a cég eredeti email címével,
|
||||
majd 6-jegyű OTP kódot küld ki.
|
||||
"""
|
||||
# Ellenőrizzük a szervezetet
|
||||
stmt = select(Organization).where(
|
||||
Organization.id == org_id,
|
||||
Organization.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Szervezet nem található.")
|
||||
|
||||
# Ellenőrizzük, hogy tényleg árva-e (nincs aktív admin)
|
||||
stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN])
|
||||
).join(User, OrganizationMember.user_id == User.id).where(User.is_active == True, User.is_deleted == False)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
active_admins = result.scalars().all()
|
||||
|
||||
if active_admins:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A szervezetnek van aktív adminja. Használja a /join-request végpontot."
|
||||
)
|
||||
|
||||
# Ellenőrizzük az email címet - keressük a tulajdonos usert
|
||||
# A tulajdonos email-jét kell ellenőrizni
|
||||
owner_stmt = select(User).where(
|
||||
User.id == org.owner_id,
|
||||
User.email == request.email
|
||||
)
|
||||
owner_result = await db.execute(owner_stmt)
|
||||
owner_user = owner_result.scalar_one_or_none()
|
||||
|
||||
if not owner_user:
|
||||
# Lehet, hogy a tulajdonos törölve van, és az email át lett írva
|
||||
# Keressük a deleted_ prefix-es email-ben
|
||||
owner_stmt = select(User).where(
|
||||
User.id == org.owner_id,
|
||||
User.email.like(f"%{request.email}")
|
||||
)
|
||||
owner_result = await db.execute(owner_stmt)
|
||||
owner_user = owner_result.scalar_one_or_none()
|
||||
|
||||
if not owner_user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Az email cím nem egyezik a cég tulajdonosának email címével."
|
||||
)
|
||||
|
||||
# Generáljunk 6-jegyű OTP kódot
|
||||
code = f"{random.randint(0, 999999):06d}"
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
|
||||
otp = OneTimePassword(
|
||||
email=request.email,
|
||||
code=code,
|
||||
otp_type="company_claim",
|
||||
extra_data={"org_id": org_id, "user_id": current_user.id},
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(otp)
|
||||
await db.commit()
|
||||
|
||||
# Szimulált email küldés
|
||||
logger.info(f"=== CÉG ÁTVÉTELI OTP ===")
|
||||
logger.info(f"Email: {request.email}")
|
||||
logger.info(f"Kód: {code}")
|
||||
logger.info(f"Lejárat: {expires_at}")
|
||||
logger.info(f"=== A kódot email-ben kell elküldeni a cég tulajdonosának ===")
|
||||
|
||||
return {
|
||||
"status": "otp_sent",
|
||||
"message": "Egy 6-jegyű megerősítő kódot küldtünk a cég tulajdonosának email címére.",
|
||||
"expires_at": expires_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{org_id}/claim/verify")
|
||||
async def claim_orphaned_organization_verify(
|
||||
org_id: int,
|
||||
request: ClaimVerifyIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Árva cég átvételének véglegesítése OTP kód alapján.
|
||||
Sikeres ellenőrzés után a próbálkozó lesz az új admin.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Ellenőrizzük a szervezetet
|
||||
stmt = select(Organization).where(
|
||||
Organization.id == org_id,
|
||||
Organization.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Szervezet nem található.")
|
||||
|
||||
# Ellenőrizzük az OTP kódot
|
||||
otp_stmt = select(OneTimePassword).where(
|
||||
OneTimePassword.email == request.email,
|
||||
OneTimePassword.code == request.code,
|
||||
OneTimePassword.otp_type == "company_claim",
|
||||
OneTimePassword.is_used == False,
|
||||
OneTimePassword.expires_at > now
|
||||
).order_by(OneTimePassword.created_at.desc()).limit(1)
|
||||
|
||||
result = await db.execute(otp_stmt)
|
||||
otp = result.scalar_one_or_none()
|
||||
|
||||
if not otp:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Érvénytelen vagy lejárt kód."
|
||||
)
|
||||
|
||||
# Ellenőrizzük, hogy az OTP ehhez a szervezethez tartozik-e
|
||||
extra = otp.extra_data or {}
|
||||
if extra.get("org_id") != org_id:
|
||||
raise HTTPException(status_code=400, detail="A kód nem ehhez a szervezethez tartozik.")
|
||||
|
||||
# Mark OTP as used
|
||||
otp.is_used = True
|
||||
|
||||
# Ellenőrizzük, hogy a felhasználó már nem tag-e
|
||||
member_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
result = await db.execute(member_stmt)
|
||||
existing_member = result.scalar_one_or_none()
|
||||
|
||||
if existing_member:
|
||||
# Frissítjük a szerepkört
|
||||
existing_member.role = OrgUserRole.ADMIN
|
||||
else:
|
||||
# Új tag létrehozása ADMIN szerepkörrel
|
||||
new_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
user_id=current_user.id,
|
||||
role=OrgUserRole.ADMIN,
|
||||
status="active"
|
||||
)
|
||||
db.add(new_member)
|
||||
|
||||
# Átírjuk a tulajdonost (owner_id)
|
||||
org.owner_id = current_user.id
|
||||
|
||||
# Naplózás
|
||||
await security_service.log_event(
|
||||
db, user_id=current_user.id, action="ORG_CLAIMED",
|
||||
severity=LogSeverity.warning, target_type="Organization", target_id=str(org_id),
|
||||
new_data={"previous_owner": str(extra.get("user_id")), "claim_type": "orphaned"}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Sikeresen átvetted a szervezet irányítását. Mostantól te vagy az admin."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#/opt/docker/dev/service_finder/backend/app/api/v1/endpoints/users.py
|
||||
import copy
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy import select, or_
|
||||
@@ -117,6 +117,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
|
||||
"active_organization_id": active_org_id,
|
||||
"preferred_language": user.preferred_language or "hu",
|
||||
"person": person_data,
|
||||
"is_last_admin": False, # Default, will be overridden in read_users_me
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +176,10 @@ async def read_users_me(
|
||||
|
||||
active_org_id = org_row[0] if org_row else None
|
||||
|
||||
# Check if user is the last admin in any organization
|
||||
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
|
||||
response_data = _build_user_response(current_user, active_org_id)
|
||||
response_data["is_last_admin"] = is_last_admin
|
||||
return UserResponse.model_validate(response_data)
|
||||
|
||||
|
||||
@@ -497,3 +501,35 @@ async def get_my_network(
|
||||
level2=level2,
|
||||
level3=level3
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/me", status_code=200)
|
||||
async def delete_my_account(
|
||||
reason: Optional[str] = Query(None, description="Törlés oka (opcionális)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Saját fiók soft-delete.
|
||||
|
||||
Üzleti logika:
|
||||
- Anonimizálja az e-mail címet (deleted_{id}_{timestamp}_{original_email})
|
||||
- is_active = False, is_deleted = True, deleted_at = timestamp
|
||||
- Person rekordot NEM bántja (megőrizzük a jövőbeli újraaktiváláshoz)
|
||||
- Wallet és Gamification adatokat megőrzi (inaktív user miatt freeze)
|
||||
- JWT token-ek a deleted_at middleware ellenőrzés miatt automatikusan érvénytelenülnek
|
||||
"""
|
||||
success = await AuthService.soft_delete_user(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
reason=reason or "user_requested_self_delete",
|
||||
actor_id=current_user.id
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A felhasználó már törölve van."
|
||||
)
|
||||
|
||||
return {"status": "ok", "message": "Fiók sikeresen törölve."}
|
||||
|
||||
@@ -52,15 +52,22 @@ def generate_secure_slug(length: int = 16) -> str:
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
# Teljesen a margón van, így globális konstans lesz!
|
||||
# Lefedi a UserRole enum MIND a 10 értékét
|
||||
DEFAULT_RANK_MAP = {
|
||||
"SUPERADMIN": 100,
|
||||
"ADMIN": 90,
|
||||
"AUDITOR": 80,
|
||||
"ORGANIZATION_OWNER": 70,
|
||||
"REGION_ADMIN": 85,
|
||||
"COUNTRY_ADMIN": 80,
|
||||
"MODERATOR": 75,
|
||||
"AUDITOR": 70,
|
||||
"ORGANIZATION_OWNER": 65,
|
||||
"ORGANIZATION_MANAGER": 60,
|
||||
"ORGANIZATION_MEMBER": 50,
|
||||
"SERVICE_PROVIDER": 40,
|
||||
"SALES_AGENT": 30,
|
||||
"FLEET_MANAGER": 25,
|
||||
"PREMIUM_USER": 20,
|
||||
"USER": 10,
|
||||
"DRIVER": 5,
|
||||
"GUEST": 0
|
||||
}
|
||||
@@ -3,23 +3,23 @@
|
||||
from app.database import Base
|
||||
|
||||
# 1. Alapvető identitás és szerepkörök
|
||||
from .identity.identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole
|
||||
from .identity.identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole, OneTimePassword
|
||||
|
||||
# 2. Földrajzi adatok és címek
|
||||
# 2. Szervezeti felépítés (MUST be before Address/Rating due to FK references)
|
||||
from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, Branch
|
||||
|
||||
# 3. Földrajzi adatok és címek
|
||||
from .identity.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating
|
||||
|
||||
# 3. Jármű definíciók
|
||||
# 4. Jármű definíciók
|
||||
from .vehicle.vehicle_definitions import VehicleModelDefinition, VehicleType, FeatureDefinition, ModelFeatureMap
|
||||
from .reference_data import ReferenceLookup
|
||||
from .vehicle.vehicle import CostCategory, VehicleCost, GbCatalogDiscovery
|
||||
from .vehicle.external_reference import ExternalReferenceLibrary
|
||||
from .vehicle.external_reference_queue import ExternalReferenceQueue
|
||||
|
||||
# 4. Szervezeti felépítés
|
||||
from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, Branch
|
||||
|
||||
# 5. Eszközök és katalógusok
|
||||
from .vehicle.asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetAssignment, AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership
|
||||
from .vehicle.asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetAssignment, AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership, OdometerReading
|
||||
|
||||
# 6. Üzleti logika és előfizetések
|
||||
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
|
||||
@@ -61,7 +61,7 @@ __all__ = [
|
||||
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount",
|
||||
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole",
|
||||
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
|
||||
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery",
|
||||
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
|
||||
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
|
||||
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution",
|
||||
|
||||
@@ -81,5 +81,5 @@ __all__ = [
|
||||
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition", "ReferenceLookup",
|
||||
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
|
||||
"Location", "LocationType", "Issuer", "IssuerType", "CostCategory", "VehicleCost", "ExternalReferenceLibrary", "ExternalReferenceQueue",
|
||||
"GbCatalogDiscovery", "Season", "StagedVehicleData"
|
||||
"GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword"
|
||||
]
|
||||
@@ -41,6 +41,29 @@ class OrganizationSubscription(Base):
|
||||
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
class UserSubscription(Base):
|
||||
"""
|
||||
Felhasználók aktuális előfizetései és azok érvényessége.
|
||||
Tábla: finance.user_subscriptions
|
||||
"""
|
||||
__tablename__ = "user_subscriptions"
|
||||
__table_args__ = {"schema": "finance"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# Kapcsolat a felhasználóval (identity séma)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
|
||||
|
||||
# Kapcsolat a csomaggal (system séma)
|
||||
tier_id: Mapped[int] = mapped_column(Integer, ForeignKey("system.subscription_tiers.id"), nullable=False)
|
||||
|
||||
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class CreditTransaction(Base):
|
||||
"""
|
||||
Kreditnapló (Pontok, kreditek vagy virtuális egyenleg követése).
|
||||
@@ -73,4 +96,21 @@ class ServiceSpecialty(Base):
|
||||
slug: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
|
||||
# Kapcsolat az ős-szolgáltatással (Self-referential relationship)
|
||||
parent: Mapped[Optional["ServiceSpecialty"]] = relationship("ServiceSpecialty", remote_side=[id], backref="children")
|
||||
parent: Mapped[Optional["ServiceSpecialty"]] = relationship("ServiceSpecialty", remote_side=[id], backref="children")
|
||||
|
||||
|
||||
class ServiceCatalog(Base):
|
||||
"""
|
||||
Szolgáltatás Katalógus (Service Catalog).
|
||||
A rendszer által kínált szolgáltatások definíciói, pl. 'SRV_DATA_EXPORT'.
|
||||
Tábla: system.service_catalog
|
||||
"""
|
||||
__tablename__ = "service_catalog"
|
||||
__table_args__ = {"schema": "system"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT'
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(String)
|
||||
credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
@@ -10,6 +10,7 @@ from .identity import (
|
||||
UserRole,
|
||||
Device,
|
||||
UserDeviceLink,
|
||||
OneTimePassword,
|
||||
)
|
||||
|
||||
from .address import (
|
||||
@@ -42,6 +43,7 @@ __all__ = [
|
||||
"UserRole",
|
||||
"Device",
|
||||
"UserDeviceLink",
|
||||
"OneTimePassword",
|
||||
"Address",
|
||||
"GeoPostalCode",
|
||||
"GeoStreet",
|
||||
|
||||
@@ -78,6 +78,9 @@ class Person(Base):
|
||||
index=True
|
||||
)
|
||||
|
||||
# === SOFT DELETE (structure only - never delete Person data) ===
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), nullable=False)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
@@ -147,6 +150,9 @@ class User(Base):
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# === SOFT DELETE ===
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True)
|
||||
|
||||
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")
|
||||
@@ -214,19 +220,24 @@ class User(Base):
|
||||
service_requests: Mapped[List["ServiceRequest"]] = relationship("ServiceRequest", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
class Wallet(Base):
|
||||
""" Felhasználói pénztárca. """
|
||||
""" Felhasználói / Szervezeti pénztárca (B2B & B2C). """
|
||||
__tablename__ = "wallets"
|
||||
__table_args__ = {"schema": "identity"}
|
||||
__table_args__ = (
|
||||
# Biztosítjuk, hogy vagy user_id, vagy organization_id legyen kitöltve, de ne egyszerre
|
||||
# Ezt ORM szinten is ellenőrizzük, de DB constraint is véd
|
||||
{"schema": "identity"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), unique=True)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), unique=True, nullable=True)
|
||||
organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=True, index=True)
|
||||
|
||||
earned_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
|
||||
purchased_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
|
||||
service_coins: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
|
||||
|
||||
currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
||||
user: Mapped["User"] = relationship("User", back_populates="wallet")
|
||||
user: Mapped[Optional["User"]] = relationship("User", back_populates="wallet")
|
||||
active_vouchers: Mapped[List["ActiveVoucher"]] = relationship("ActiveVoucher", back_populates="wallet", cascade="all, delete-orphan")
|
||||
|
||||
class VerificationToken(Base):
|
||||
@@ -306,6 +317,25 @@ class UserTrustProfile(Base):
|
||||
user: Mapped["User"] = relationship("User", back_populates="trust_profile", uselist=False)
|
||||
|
||||
|
||||
class OneTimePassword(Base):
|
||||
"""
|
||||
6-jegyű OTP (One Time Password) ideiglenes tárolása.
|
||||
Típusok: 'restore' (fiók visszaállítás), 'company_claim' (cég átvétel)
|
||||
Lejárati idő: 15 perc.
|
||||
"""
|
||||
__tablename__ = "one_time_passwords"
|
||||
__table_args__ = {"schema": "identity"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(6), nullable=False)
|
||||
otp_type: Mapped[str] = mapped_column(String(30), nullable=False) # 'restore' or 'company_claim'
|
||||
extra_data: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"), nullable=True)
|
||||
is_used: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
|
||||
class Device(Base):
|
||||
""" Ismert eszközök (Device Fingerprinting) a Device-Hub architektúrához. """
|
||||
__tablename__ = "devices"
|
||||
|
||||
@@ -93,6 +93,8 @@ class Organization(Base):
|
||||
default=OrgType.individual
|
||||
)
|
||||
|
||||
is_affiliate_partner: Mapped[bool] = mapped_column(Boolean, server_default=text('false'), default=False)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(30), default="pending_verification")
|
||||
|
||||
# Soft delete: is_active=False és is_deleted=True esetén a cég 'törölt'
|
||||
|
||||
@@ -10,7 +10,6 @@ from .vehicle_definitions import (
|
||||
from .vehicle import (
|
||||
CostCategory,
|
||||
VehicleCost,
|
||||
VehicleOdometerState,
|
||||
VehicleUserRating,
|
||||
GbCatalogDiscovery,
|
||||
)
|
||||
@@ -28,6 +27,7 @@ from .asset import (
|
||||
ExchangeRate,
|
||||
CatalogDiscovery,
|
||||
VehicleOwnership,
|
||||
OdometerReading,
|
||||
)
|
||||
|
||||
from .history import AuditLog, LogSeverity
|
||||
@@ -42,7 +42,6 @@ __all__ = [
|
||||
"ModelFeatureMap",
|
||||
"CostCategory",
|
||||
"VehicleCost",
|
||||
"VehicleOdometerState",
|
||||
"VehicleUserRating",
|
||||
"GbCatalogDiscovery",
|
||||
"ExternalReferenceLibrary",
|
||||
@@ -62,4 +61,5 @@ __all__ = [
|
||||
# --- EXPORT LISTA KIEGÉSZÍTÉSE ---
|
||||
"MotorcycleSpecs",
|
||||
"BodyTypeDictionary",
|
||||
"OdometerReading",
|
||||
]
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, BigInteger, Integer, Float
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, CheckConstraint, BigInteger, Integer, Float
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
@@ -69,7 +69,13 @@ class RoofTypeEnum(str, enum.Enum):
|
||||
class Asset(Base):
|
||||
""" A fizikai eszköz (Digital Twin) - Minden adat itt fut össze. """
|
||||
__tablename__ = "assets"
|
||||
__table_args__ = {"schema": "vehicle"}
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"vin IS NOT NULL OR license_plate IS NOT NULL",
|
||||
name="ck_asset_vin_or_plate_required"
|
||||
),
|
||||
{"schema": "vehicle"}
|
||||
)
|
||||
|
||||
# === IDENTIFICATION ===
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
@@ -147,6 +153,7 @@ class Asset(Base):
|
||||
assignments: Mapped[List["AssetAssignment"]] = relationship("AssetAssignment", back_populates="asset")
|
||||
ownership_history: Mapped[List["VehicleOwnership"]] = relationship("VehicleOwnership", back_populates="asset")
|
||||
service_requests: Mapped[List["ServiceRequest"]] = relationship("ServiceRequest", back_populates="asset", cascade="all, delete-orphan")
|
||||
odometer_readings: Mapped[List["OdometerReading"]] = relationship("OdometerReading", back_populates="asset", order_by="OdometerReading.recorded_at.desc()")
|
||||
|
||||
# --- COMPUTED PROPERTIES (for Pydantic schema compatibility) ---
|
||||
@property
|
||||
@@ -249,6 +256,7 @@ class AssetCost(Base):
|
||||
currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
||||
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True)
|
||||
|
||||
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
|
||||
@@ -342,6 +350,23 @@ class AssetTelemetry(Base):
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="telemetry")
|
||||
|
||||
|
||||
class OdometerReading(Base):
|
||||
""" Km óra állás rögzítése (időbeli nyomonkövetés). """
|
||||
__tablename__ = "odometer_readings"
|
||||
__table_args__ = {"schema": "vehicle"}
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), nullable=False, index=True)
|
||||
reading: Mapped[int] = mapped_column(Integer, nullable=False) # km óra állás
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
source: Mapped[str] = mapped_column(String(30), default="manual") # manual, api, telemetry
|
||||
cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.asset_costs.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="odometer_readings")
|
||||
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost")
|
||||
|
||||
|
||||
class AssetAssignment(Base):
|
||||
""" Eszköz-Szervezet összerendelés. """
|
||||
__tablename__ = "asset_assignments"
|
||||
|
||||
@@ -6,11 +6,11 @@ TCO (Total Cost of Ownership) alapmodelljei a 'vehicle' sémában.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Numeric, UniqueConstraint, Float, CheckConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Numeric, UniqueConstraint, CheckConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
@@ -22,7 +22,7 @@ class CostCategory(Base):
|
||||
Rendszerkategóriák (is_system=True) nem törölhetők, csak felhasználói kategóriák.
|
||||
"""
|
||||
__tablename__ = "cost_categories"
|
||||
__table_args__ = {"schema": "vehicle"}
|
||||
__table_args__ = {"schema": "vehicle", "extend_existing": True}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(
|
||||
@@ -36,6 +36,7 @@ class CostCategory(Base):
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'")
|
||||
min_tier: Mapped[str] = mapped_column(String(20), default="free", server_default="'free'") # free, premium, enterprise
|
||||
accounting_code: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
@@ -108,36 +109,6 @@ class VehicleCost(Base):
|
||||
return f"VehicleCost(id={self.id}, vehicle_id={self.vehicle_id}, amount={self.amount} {self.currency})"
|
||||
|
||||
|
||||
class VehicleOdometerState(Base):
|
||||
"""
|
||||
Jármű kilométeróra állapotának és becslésének tárolása.
|
||||
Adminisztrátor által paraméterezhető algoritmusokkal működik.
|
||||
"""
|
||||
__tablename__ = "vehicle_odometer_states"
|
||||
__table_args__ = {"schema": "vehicle"}
|
||||
|
||||
vehicle_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("vehicle.vehicle_model_definitions.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
nullable=False
|
||||
)
|
||||
last_recorded_odometer: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
last_recorded_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
daily_avg_distance: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
estimated_current_odometer: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
confidence_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
manual_override_avg: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
# Kapcsolat a jármű definícióval
|
||||
vehicle: Mapped["VehicleModelDefinition"] = relationship("VehicleModelDefinition", back_populates="odometer_state")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"VehicleOdometerState(vehicle_id={self.vehicle_id}, estimated={self.estimated_current_odometer}, confidence={self.confidence_score})"
|
||||
|
||||
|
||||
class VehicleUserRating(Base):
|
||||
"""
|
||||
Jármű értékelési rendszer - User -> Vehicle kapcsolat.
|
||||
@@ -155,7 +126,7 @@ class VehicleUserRating(Base):
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
PG_UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid()
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.database import Base
|
||||
# Típus ellenőrzés a körkörös importok elkerülésére
|
||||
if TYPE_CHECKING:
|
||||
from .asset import AssetCatalog
|
||||
from .vehicle import VehicleCost, VehicleOdometerState, VehicleUserRating
|
||||
from .vehicle import VehicleCost, VehicleUserRating
|
||||
|
||||
class VehicleType(Base):
|
||||
""" Jármű kategóriák (pl. Személyautó, Motorkerékpár, Teherautó, Hajó) """
|
||||
@@ -106,6 +106,10 @@ class VehicleModelDefinition(Base):
|
||||
transmission_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
drive_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
# --- SZERVIZ INTERVALLUMOK ---
|
||||
service_interval_km: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
service_interval_months: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# --- ÉLETCIKLUS ---
|
||||
year_from: Mapped[int] = mapped_column(Integer, index=True, server_default=text("0")) # EZT IS BELETETTÜK A KULCSBA
|
||||
year_to: Mapped[Optional[int]] = mapped_column(Integer, index=True)
|
||||
@@ -139,7 +143,6 @@ class VehicleModelDefinition(Base):
|
||||
ratings: Mapped[List["VehicleUserRating"]] = relationship("VehicleUserRating", back_populates="vehicle", cascade="all, delete-orphan")
|
||||
|
||||
costs: Mapped[List["VehicleCost"]] = relationship("VehicleCost", back_populates="vehicle", cascade="all, delete-orphan")
|
||||
odometer_state: Mapped[Optional["VehicleOdometerState"]] = relationship("VehicleOdometerState", back_populates="vehicle", uselist=False, cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ModelFeatureMap(Base):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/asset.py
|
||||
from pydantic import BaseModel, ConfigDict, Field, validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator
|
||||
from typing import Optional, Dict, Any, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
@@ -113,8 +113,30 @@ class AssetResponse(BaseModel):
|
||||
class AssetCreate(BaseModel):
|
||||
""" Jármű létrehozásához szükséges adatok - Thick Digital Twin támogatással. """
|
||||
# === CORE IDENTIFICATION (Required for status determination) ===
|
||||
license_plate: str = Field(..., min_length=2, max_length=20, description="Rendszám")
|
||||
license_plate: Optional[str] = Field(None, min_length=2, max_length=20, description="Rendszám")
|
||||
vin: Optional[str] = Field(None, min_length=1, max_length=50, description="VIN szám (opcionális)")
|
||||
|
||||
# ── Pre-validator: convert empty strings to None for optional string fields ──
|
||||
@validator('license_plate', 'vin', 'trim_level',
|
||||
'cylinder_layout', 'transmission_type', 'drive_type',
|
||||
'euro_classification', 'roof_type', 'audio_system_type',
|
||||
'data_status',
|
||||
pre=True, always=True)
|
||||
def empty_str_to_none(cls, v):
|
||||
"""Convert empty strings to None so Pydantic doesn't reject them with min_length."""
|
||||
if v is not None and isinstance(v, str) and v.strip() == '':
|
||||
return None
|
||||
return v
|
||||
|
||||
# ── Root validator: ensure at least one of vin or license_plate is provided ──
|
||||
@root_validator(skip_on_failure=True)
|
||||
def validate_vin_or_plate(cls, values):
|
||||
"""Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám."""
|
||||
license_plate = values.get('license_plate')
|
||||
vin = values.get('vin')
|
||||
if not license_plate and not vin:
|
||||
raise ValueError('Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám.')
|
||||
return values
|
||||
|
||||
# === CLASSIFICATION (Optional, but affects status) ===
|
||||
brand: Optional[str] = Field(None, max_length=100, description="Márka (ha nincs catalog_id)")
|
||||
@@ -190,6 +212,35 @@ class AssetUpdate(BaseModel):
|
||||
# === CORE IDENTIFICATION ===
|
||||
license_plate: Optional[str] = Field(None, min_length=2, max_length=20, description="Rendszám")
|
||||
vin: Optional[str] = Field(None, min_length=1, max_length=50, description="VIN szám")
|
||||
|
||||
# ── Pre-validator: convert empty strings to None for optional string fields ──
|
||||
@validator('license_plate', 'vin', 'trim_level',
|
||||
'cylinder_layout', 'transmission_type', 'drive_type',
|
||||
'euro_classification', 'roof_type', 'audio_system_type',
|
||||
pre=True, always=True)
|
||||
def empty_str_to_none(cls, v):
|
||||
"""Convert empty strings to None so Pydantic doesn't reject them with min_length."""
|
||||
if v is not None and isinstance(v, str) and v.strip() == '':
|
||||
return None
|
||||
return v
|
||||
|
||||
# ── Model validator: ensure at least one of vin or license_plate is provided ──
|
||||
@model_validator(mode='after')
|
||||
def validate_vin_or_plate(self):
|
||||
"""Update esetén: ha mindkét mezőt None-ra állítják explicit, az nem engedélyezett.
|
||||
|
||||
Működés:
|
||||
- Ha egyik mező sincs a payload-ban (üres update) → engedélyezve (nincs változás)
|
||||
- Ha mindkettő None explicit → hiba (nem lehet mindkettőt elvenni)
|
||||
- Ha legalább az egyik meg van adva → OK
|
||||
"""
|
||||
# model_fields_set tartalmazza az explicit megadott mezőket
|
||||
if 'license_plate' not in self.model_fields_set and 'vin' not in self.model_fields_set:
|
||||
return self
|
||||
|
||||
if not self.license_plate and not self.vin:
|
||||
raise ValueError('Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám.')
|
||||
return self
|
||||
|
||||
# === CLASSIFICATION ===
|
||||
brand: Optional[str] = Field(None, max_length=100, description="Márka")
|
||||
|
||||
@@ -6,31 +6,30 @@ from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
class AssetCostBase(BaseModel):
|
||||
cost_type: str # fuel, service, tax, insurance
|
||||
amount_local: Decimal
|
||||
currency_local: str = "HUF"
|
||||
net_amount_local: Optional[Decimal] = None
|
||||
vat_rate: Optional[Decimal] = Field(default=27.0)
|
||||
|
||||
"""Base schema — matches the AssetCost SQLAlchemy model fields."""
|
||||
amount_net: Decimal
|
||||
currency: str = "HUF"
|
||||
date: datetime = Field(default_factory=datetime.now)
|
||||
mileage_at_cost: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták
|
||||
invoice_number: Optional[str] = None
|
||||
data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták
|
||||
category_id: int # FK a CostCategory táblához
|
||||
mileage_at_cost: Optional[int] = None # Stored inside data JSONB
|
||||
description: Optional[str] = None # Stored inside data JSONB
|
||||
|
||||
class AssetCostCreate(AssetCostBase):
|
||||
asset_id: UUID
|
||||
organization_id: Optional[int] = None # Auto-resolved from asset if not provided
|
||||
category_id: int # FK a CostCategory táblához
|
||||
|
||||
class AssetCostResponse(AssetCostBase):
|
||||
"""Response schema — matches DB columns from vehicle.asset_costs."""
|
||||
id: UUID
|
||||
asset_id: UUID
|
||||
organization_id: int
|
||||
driver_id: Optional[int] = None
|
||||
|
||||
# Pénzügyi dúsítás (Backend számolja)
|
||||
amount_eur: Optional[Decimal] = None
|
||||
exchange_rate_used: Optional[Decimal] = None
|
||||
created_at: datetime
|
||||
# Derived / enriched fields (not in DB model directly)
|
||||
category_name: Optional[str] = None # Resolved from CostCategory relationship
|
||||
category_code: Optional[str] = None # Resolved from CostCategory relationship
|
||||
description: Optional[str] = None # Extracted from data['description']
|
||||
mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost']
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -40,11 +40,26 @@ class CorpOnboardResponse(BaseModel):
|
||||
|
||||
class OrganizationUpdate(BaseModel):
|
||||
"""PATCH séma a szervezet adatainak részleges frissítéséhez.
|
||||
Támogatja a visual_settings JSONB mező biztonságos részleges frissítését."""
|
||||
Támogatja a visual_settings JSONB mező biztonságos részleges frissítését,
|
||||
valamint a cégadatok (név, adószám, cím) szerkesztését."""
|
||||
visual_settings: Optional[dict] = None
|
||||
display_name: Optional[str] = None
|
||||
default_currency: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
# ── Cégadatok (Company details) ──
|
||||
name: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
tax_number: Optional[str] = None
|
||||
# ── Cím adatok (Address fields) ──
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
address_stairwell: Optional[str] = None
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
|
||||
|
||||
class OrganizationResponse(BaseModel):
|
||||
|
||||
33
backend/app/schemas/service_catalog.py
Normal file
33
backend/app/schemas/service_catalog.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/service_catalog.py
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ServiceCatalogCreate(BaseModel):
|
||||
"""Schema új szolgáltatás katalógus bejegyzés létrehozásához."""
|
||||
service_code: str = Field(..., min_length=3, max_length=50, description="Egyedi szolgáltatás kód (pl. 'SRV_DATA_EXPORT')")
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltatás megjelenített neve")
|
||||
description: Optional[str] = Field(None, max_length=1000, description="Részletes leírás")
|
||||
credit_cost: int = Field(0, ge=0, description="Alapértelmezett kredit költség")
|
||||
is_active: bool = Field(True, description="Aktív-e a szolgáltatás")
|
||||
|
||||
|
||||
class ServiceCatalogUpdate(BaseModel):
|
||||
"""Schema meglévő szolgáltatás katalógus bejegyzés módosításához."""
|
||||
name: Optional[str] = Field(None, min_length=2, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=1000)
|
||||
credit_cost: Optional[int] = Field(None, ge=0)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ServiceCatalogResponse(BaseModel):
|
||||
"""Schema szolgáltatás katalógus bejegyzés visszaadásához."""
|
||||
id: int
|
||||
service_code: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
credit_cost: int
|
||||
is_active: bool
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
143
backend/app/schemas/subscription.py
Normal file
143
backend/app/schemas/subscription.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
📦 Subscription Tier Pydantic Schemas
|
||||
|
||||
A subscription_tiers JSONB rules mezőjének erősen tipizált Pydantic modelljei.
|
||||
A rules struktúra a seed_packages.py-ban definiált formátumot követi:
|
||||
|
||||
{
|
||||
"type": "private" | "corporate",
|
||||
"pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
|
||||
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
|
||||
"entitlements": ["SRV_..."],
|
||||
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
# ── Belső (embedded) modellek a rules JSONB struktúrához ──────────────────────
|
||||
|
||||
|
||||
class PricingModel(BaseModel):
|
||||
"""Árazási információk egy előfizetési csomaghoz."""
|
||||
monthly_price: float = Field(..., ge=0, description="Havi előfizetési díj (EUR)")
|
||||
yearly_price: float = Field(..., ge=0, description="Éves előfizetési díj (EUR)")
|
||||
currency: str = Field(default="EUR", min_length=3, max_length=3, description="Pénznem ISO 4217 kódja")
|
||||
credit_price: Optional[int] = Field(default=None, ge=0, description="Kreditár (havi kredit költség a csomaghoz)")
|
||||
|
||||
|
||||
class AllowancesModel(BaseModel):
|
||||
"""Korlátok és keretek a csomagban."""
|
||||
max_vehicles: int = Field(..., ge=0, description="Maximális járműszám")
|
||||
max_garages: int = Field(..., ge=0, description="Maximális garázsok száma")
|
||||
monthly_free_credits: int = Field(..., ge=0, description="Havi ingyenes kreditek száma")
|
||||
|
||||
|
||||
class LifecycleModel(BaseModel):
|
||||
"""Életciklus állapotok egy előfizetési csomaghoz."""
|
||||
is_public: bool = Field(default=True, description="Publikus-e a csomag (látszik-e a felhasználóknak)")
|
||||
available_until: Optional[datetime] = Field(default=None, description="Meddig érhető el a csomag (None = nincs lejárat)")
|
||||
|
||||
|
||||
class AffiliateModel(BaseModel):
|
||||
"""Partnerprogram (affiliate) beállítások."""
|
||||
commission_rate_percent: int = Field(..., ge=0, le=100, description="Jutalék mértéke százalékban")
|
||||
referral_bonus_credits: int = Field(..., ge=0, description="Ajánlási bónusz kreditek száma")
|
||||
|
||||
|
||||
class SubscriptionRulesModel(BaseModel):
|
||||
"""
|
||||
A subscription_tiers.rules JSONB oszlopának erősen tipizált modellje.
|
||||
|
||||
Fields:
|
||||
type: "private" (magánszemély) vagy "corporate" (vállalati)
|
||||
display_name: Emberi olvasásra szánt megjelenítési név (pl. "Privát Ingyenes")
|
||||
pricing: Árazási adatok (havi/éves díj, pénznem)
|
||||
allowances: Korlátok (max jármű, garázs, kredit)
|
||||
entitlements: Jogosultságok listája (pl. SRV_DATA_EXPORT)
|
||||
affiliate: Partnerprogram beállítások
|
||||
lifecycle: Életciklus állapotok (opcionális, pl. is_public)
|
||||
"""
|
||||
type: str = Field(..., pattern=r"^(private|corporate)$", description="Csomag típusa")
|
||||
display_name: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Emberi olvasásra szánt megjelenítési név (pl. 'Privát Ingyenes')",
|
||||
)
|
||||
pricing: Optional[PricingModel] = Field(
|
||||
default=None,
|
||||
description="Árazási adatok (havi/éves díj, pénznem). Opcionális a hiányos seed adatok miatt."
|
||||
)
|
||||
allowances: Optional[AllowancesModel] = Field(
|
||||
default=None,
|
||||
description="Korlátok (max jármű, garázs, kredit). Opcionális a hiányos seed adatok miatt."
|
||||
)
|
||||
entitlements: List[str] = Field(default_factory=list, description="Jogosultságok (entitlement) listája")
|
||||
affiliate: Optional[AffiliateModel] = Field(
|
||||
default=None,
|
||||
description="Partnerprogram beállítások. Opcionális a hiányos seed adatok miatt."
|
||||
)
|
||||
lifecycle: Optional[LifecycleModel] = Field(
|
||||
default=None,
|
||||
description="Életciklus állapotok (is_public, available_until)"
|
||||
)
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def validate_type(cls, v: str) -> str:
|
||||
if v.lower() not in ("private", "corporate"):
|
||||
raise ValueError("A 'type' mező csak 'private' vagy 'corporate' lehet.")
|
||||
return v.lower()
|
||||
|
||||
|
||||
# ── API Response / Request modellek ───────────────────────────────────────────
|
||||
|
||||
|
||||
class SubscriptionTierResponse(BaseModel):
|
||||
"""Teljes SubscriptionTier rekord visszaadása (GET /, GET /{id})."""
|
||||
id: int
|
||||
name: str
|
||||
rules: SubscriptionRulesModel
|
||||
is_custom: bool
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SubscriptionTierCreate(BaseModel):
|
||||
"""Új előfizetési csomag létrehozása (POST /)."""
|
||||
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
|
||||
rules: SubscriptionRulesModel
|
||||
is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("A csomag neve nem lehet üres.")
|
||||
return v.strip().lower()
|
||||
|
||||
|
||||
class SubscriptionTierUpdate(BaseModel):
|
||||
"""Meglévő előfizetési csomag részleges frissítése (PATCH /{tier_id})."""
|
||||
name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév")
|
||||
rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB")
|
||||
is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is not None and not v.strip():
|
||||
raise ValueError("A csomag neve nem lehet üres.")
|
||||
return v.strip().lower() if v else v
|
||||
|
||||
|
||||
class SubscriptionTierListResponse(BaseModel):
|
||||
"""Csomagok listázásának válaszformátuma (GET /)."""
|
||||
total: int
|
||||
tiers: List[SubscriptionTierResponse]
|
||||
@@ -64,6 +64,8 @@ class UserResponse(UserBase):
|
||||
preferred_language: str = "hu"
|
||||
# Beágyazott személyes adatok (Person + Address)
|
||||
person: Optional[PersonResponse] = None
|
||||
# Utolsó admin flag (ha a user az egyetlen aktív admin bármely szervezetében)
|
||||
is_last_admin: bool = False
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
|
||||
241
backend/app/scripts/cleanup_user.py
Normal file
241
backend/app/scripts/cleanup_user.py
Normal file
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hard Delete Cleanup Script — Cascade törlés User és kapcsolódó rekordokra.
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/cleanup_user.py --email %test%
|
||||
|
||||
Az --email paraméter részleges egyezést is támogat (SQL LIKE, pl. %test%@%.
|
||||
A szkript tranzakcióban törli:
|
||||
- User rekordot
|
||||
- Person rekordot (ha a User volt az egyetlen kapcsolódó)
|
||||
- Organization rekordot (ha a User volt az egyetlen owner)
|
||||
- Session tokeneket, SocialAccount-okat, Wallet-eket, stb.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select, delete, text
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
# Projekt importok
|
||||
from app.database import async_session_factory
|
||||
from app.models.identity import User, Person, UserRole
|
||||
from app.models.identity.identity import (
|
||||
SocialAccount, Wallet, ActiveVoucher, VerificationToken,
|
||||
UserDeviceLink, UserTrustProfile, OneTimePassword,
|
||||
)
|
||||
from app.models.gamification.gamification import UserStats
|
||||
from app.models.marketplace.service_request import ServiceRequest
|
||||
from app.models.social import ServiceReview
|
||||
from app.models.vehicle.asset import VehicleOwnership
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("cleanup_user")
|
||||
|
||||
|
||||
async def cleanup_user(email_pattern: str) -> None:
|
||||
"""
|
||||
Megkeresi a megadott email mintára illeszkedő usereket,
|
||||
és cascade hard delete-et hajt végre rajtuk.
|
||||
"""
|
||||
async with async_session_factory() as db:
|
||||
# 1. Keresés részleges email egyezéssel
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.email.ilike(email_pattern))
|
||||
.options(
|
||||
joinedload(User.person),
|
||||
joinedload(User.stats),
|
||||
joinedload(User.wallet),
|
||||
joinedload(User.trust_profile),
|
||||
joinedload(User.ownership_history),
|
||||
joinedload(User.social_accounts),
|
||||
joinedload(User.service_reviews),
|
||||
joinedload(User.service_requests),
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
users = result.unique().scalars().all()
|
||||
|
||||
if not users:
|
||||
logger.warning("❌ Nem található user a '%s' mintára.", email_pattern)
|
||||
return
|
||||
|
||||
logger.info("🔍 Talált userek (%d db):", len(users))
|
||||
for u in users:
|
||||
logger.info(" - ID=%d, Email=%s, Role=%s", u.id, u.email, u.role.value if hasattr(u.role, 'value') else u.role)
|
||||
|
||||
# 2. Tranzakció indítása
|
||||
async with db.begin():
|
||||
for user in users:
|
||||
user_id = user.id
|
||||
email = user.email
|
||||
logger.info("🗑️ Törlés: User ID=%d, Email=%s", user_id, email)
|
||||
|
||||
# --- Cascade törlés ---
|
||||
|
||||
# UserDeviceLink
|
||||
await db.execute(
|
||||
delete(UserDeviceLink).where(UserDeviceLink.user_id == user_id)
|
||||
)
|
||||
logger.info(" ✓ UserDeviceLink törölve")
|
||||
|
||||
# VerificationToken
|
||||
await db.execute(
|
||||
delete(VerificationToken).where(VerificationToken.user_id == user_id)
|
||||
)
|
||||
logger.info(" ✓ VerificationToken törölve")
|
||||
|
||||
# OneTimePassword
|
||||
await db.execute(
|
||||
delete(OneTimePassword).where(OneTimePassword.email == email)
|
||||
)
|
||||
logger.info(" ✓ OneTimePassword törölve")
|
||||
|
||||
# SocialAccount (cascade all, delete-orphan)
|
||||
await db.execute(
|
||||
delete(SocialAccount).where(SocialAccount.user_id == user_id)
|
||||
)
|
||||
logger.info(" ✓ SocialAccount törölve")
|
||||
|
||||
# UserTrustProfile (cascade all, delete-orphan)
|
||||
await db.execute(
|
||||
delete(UserTrustProfile).where(UserTrustProfile.user_id == user_id)
|
||||
)
|
||||
logger.info(" ✓ UserTrustProfile törölve")
|
||||
|
||||
# UserStats (gamification)
|
||||
await db.execute(
|
||||
delete(UserStats).where(UserStats.user_id == user_id)
|
||||
)
|
||||
logger.info(" ✓ UserStats törölve")
|
||||
|
||||
# Wallet + ActiveVoucher (cascade)
|
||||
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id)
|
||||
wallet_result = await db.execute(wallet_stmt)
|
||||
wallet = wallet_result.scalar_one_or_none()
|
||||
if wallet:
|
||||
await db.execute(
|
||||
delete(ActiveVoucher).where(ActiveVoucher.wallet_id == wallet.id)
|
||||
)
|
||||
await db.execute(
|
||||
delete(Wallet).where(Wallet.id == wallet.id)
|
||||
)
|
||||
logger.info(" ✓ Wallet + ActiveVoucher törölve")
|
||||
|
||||
# ServiceReview
|
||||
await db.execute(
|
||||
delete(ServiceReview).where(ServiceReview.user_id == user_id)
|
||||
)
|
||||
logger.info(" ✓ ServiceReview törölve")
|
||||
|
||||
# ServiceRequest
|
||||
await db.execute(
|
||||
delete(ServiceRequest).where(ServiceRequest.user_id == user_id)
|
||||
)
|
||||
logger.info(" ✓ ServiceRequest törölve")
|
||||
|
||||
# VehicleOwnership
|
||||
await db.execute(
|
||||
delete(VehicleOwnership).where(VehicleOwnership.user_id == user_id)
|
||||
)
|
||||
logger.info(" ✓ VehicleOwnership törölve")
|
||||
|
||||
# Person rekord kezelése
|
||||
person = user.person
|
||||
if person:
|
||||
# Ellenőrizzük, hogy más User nem hivatkozik-e erre a Person-ra
|
||||
other_users_stmt = (
|
||||
select(User)
|
||||
.where(User.person_id == person.id, User.id != user_id)
|
||||
.limit(1)
|
||||
)
|
||||
other_result = await db.execute(other_users_stmt)
|
||||
other_user = other_result.scalar_one_or_none()
|
||||
|
||||
if not other_user:
|
||||
# Nincs más User ehhez a Person-hoz -> törölhető
|
||||
await db.execute(
|
||||
delete(Person).where(Person.id == person.id)
|
||||
)
|
||||
logger.info(" ✓ Person ID=%d törölve (egyedüli kapcsolat)", person.id)
|
||||
else:
|
||||
logger.info(
|
||||
" ⚠️ Person ID=%d MEGTARTVA (más User is hivatkozik rá: ID=%d)",
|
||||
person.id, other_user.id
|
||||
)
|
||||
|
||||
# Organization kezelése (ha a User volt az egyetlen owner)
|
||||
from app.models.identity.organization import Organization
|
||||
org_stmt = (
|
||||
select(Organization)
|
||||
.where(Organization.owner_id == user_id)
|
||||
)
|
||||
org_result = await db.execute(org_stmt)
|
||||
owned_orgs = org_result.scalars().all()
|
||||
|
||||
for org in owned_orgs:
|
||||
# Ellenőrizzük, hogy más User nem owner-e
|
||||
other_owner_stmt = (
|
||||
select(User)
|
||||
.where(User.id != user_id, User.id.isnot(None))
|
||||
.limit(1)
|
||||
)
|
||||
# Egyszerűbb: csak töröljük, ha nincs más owner
|
||||
# (a gyakorlatban az Organization-nak lehet több tagja)
|
||||
from app.models.identity.organization import OrganizationMember
|
||||
member_stmt = (
|
||||
select(OrganizationMember)
|
||||
.where(
|
||||
OrganizationMember.organization_id == org.id,
|
||||
OrganizationMember.user_id != user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
member_result = await db.execute(member_stmt)
|
||||
other_member = member_result.scalar_one_or_none()
|
||||
|
||||
if not other_member:
|
||||
await db.execute(
|
||||
delete(Organization).where(Organization.id == org.id)
|
||||
)
|
||||
logger.info(" ✓ Organization ID=%d törölve (egyedüli owner)", org.id)
|
||||
else:
|
||||
logger.info(
|
||||
" ⚠️ Organization ID=%d MEGTARTVA (van más tag)",
|
||||
org.id
|
||||
)
|
||||
|
||||
# VÉGÜL: Maga a User rekord törlése
|
||||
await db.execute(
|
||||
delete(User).where(User.id == user_id)
|
||||
)
|
||||
logger.info(" ✅ User ID=%d (%s) törölve.", user_id, email)
|
||||
|
||||
logger.info("🎉 Tranzakció sikeresen végrehajtva.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Hard Delete Cleanup — User és kapcsolódó rekordok cascade törlése."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--email",
|
||||
required=True,
|
||||
help="Email minta (SQL LIKE, pl. %%test%% vagy teszt%%).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
import asyncio
|
||||
asyncio.run(cleanup_user(args.email))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
363
backend/app/scripts/migrate_subscriptions.py
Normal file
363
backend/app/scripts/migrate_subscriptions.py
Normal file
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
📦 Nagy Előfizetés Migráció (Subscription Migration Script)
|
||||
|
||||
Cél:
|
||||
A régi, denormalizált subscription_plan mezők (identity.users, fleet.organizations)
|
||||
átvezetése a modern, normalizált subscription_tier + org_subscriptions / user_subscriptions
|
||||
architektúrába.
|
||||
|
||||
Feladatok:
|
||||
A) Létrehozza a corp_free_v1 csomagot, ha még nem létezik (0 áras, alap korlátokkal).
|
||||
B) Létrehozza a user_subscriptions táblát (finance.user_subscriptions), ha még nem létezik.
|
||||
C) Adatkonverzió: a meglévő subscription_plan string-eket átalakítja tier_id hivatkozásokká.
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/migrate_subscriptions.py
|
||||
|
||||
Architektúra:
|
||||
- system.subscription_tiers: A csomagdefiníciók (SubscriptionTier modell)
|
||||
- finance.org_subscriptions: Szervezeti előfizetések (OrganizationSubscription modell)
|
||||
- finance.user_subscriptions: Felhasználói előfizetések (UserSubscription modell - ÚJ!)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import text, select, Integer, String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import AsyncSessionLocal, Base
|
||||
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("Migrate-Subscriptions")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tiers név -> ID mapping (feltöltés után)
|
||||
# ---------------------------------------------------------------------------
|
||||
TIER_MAP: dict[str, int] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A) corp_free_v1 definíció
|
||||
# ---------------------------------------------------------------------------
|
||||
CORP_FREE_V1 = {
|
||||
"name": "corp_free_v1",
|
||||
"rules": {
|
||||
"type": "corporate",
|
||||
"display_name": "Céges Ingyenes",
|
||||
"pricing": {
|
||||
"monthly_price": 0.0,
|
||||
"yearly_price": 0.0,
|
||||
"currency": "EUR",
|
||||
"credit_price": 0,
|
||||
},
|
||||
"allowances": {
|
||||
"max_vehicles": 3,
|
||||
"max_garages": 1,
|
||||
"monthly_free_credits": 0,
|
||||
},
|
||||
"entitlements": [],
|
||||
"affiliate": {
|
||||
"commission_rate_percent": 0,
|
||||
"referral_bonus_credits": 0,
|
||||
},
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Régi plan string -> tier név mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
PLAN_TO_TIER: dict[str, str] = {
|
||||
"FREE": "private_free_v1",
|
||||
"free": "private_free_v1",
|
||||
"PRO": "private_pro_v1",
|
||||
"PREMIUM": "corp_premium_v1",
|
||||
"ENTERPRISE": "corp_vip_v1",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Segédfüggvények
|
||||
# ---------------------------------------------------------------------------
|
||||
async def _ensure_corp_free_v1(db) -> None:
|
||||
"""A) Létrehozza a corp_free_v1 csomagot, ha még nem létezik."""
|
||||
result = await db.execute(
|
||||
select(SubscriptionTier).where(SubscriptionTier.name == "corp_free_v1")
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info("✅ corp_free_v1 már létezik (ID=%d). Kihagyva.", existing.id)
|
||||
TIER_MAP["corp_free_v1"] = existing.id
|
||||
return
|
||||
|
||||
tier = SubscriptionTier(
|
||||
name=CORP_FREE_V1["name"],
|
||||
rules=CORP_FREE_V1["rules"],
|
||||
is_custom=CORP_FREE_V1["is_custom"],
|
||||
)
|
||||
db.add(tier)
|
||||
await db.commit()
|
||||
await db.refresh(tier)
|
||||
TIER_MAP["corp_free_v1"] = tier.id
|
||||
logger.info("✅ corp_free_v1 létrehozva (ID=%d).", tier.id)
|
||||
|
||||
|
||||
async def _load_tier_map(db) -> None:
|
||||
"""Betölti az összes tier nevét és ID-ját a TIER_MAP-ba."""
|
||||
result = await db.execute(select(SubscriptionTier))
|
||||
tiers = result.scalars().all()
|
||||
for t in tiers:
|
||||
TIER_MAP[t.name] = t.id
|
||||
logger.info("📋 Betöltött tier-ek: %s", {n: i for n, i in TIER_MAP.items()})
|
||||
|
||||
|
||||
def _resolve_tier_id(plan: str | None) -> int | None:
|
||||
"""Egy régi plan string-ből kikeresi a megfelelő tier ID-t."""
|
||||
if not plan:
|
||||
return None
|
||||
tier_name = PLAN_TO_TIER.get(plan)
|
||||
if not tier_name:
|
||||
logger.warning("⚠️ Ismeretlen plan: '%s' -> private_free_v1 lesz.", plan)
|
||||
tier_name = "private_free_v1"
|
||||
return TIER_MAP.get(tier_name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B) UserSubscription tábla létrehozása (ha nem létezik)
|
||||
# ---------------------------------------------------------------------------
|
||||
async def _ensure_user_subscriptions_table(db) -> None:
|
||||
"""Létrehozza a finance.user_subscriptions táblát, ha még nem létezik."""
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT EXISTS (SELECT FROM information_schema.tables "
|
||||
"WHERE table_schema = 'finance' AND table_name = 'user_subscriptions')"
|
||||
)
|
||||
)
|
||||
exists = result.scalar()
|
||||
|
||||
if exists:
|
||||
logger.info("✅ finance.user_subscriptions tábla már létezik.")
|
||||
return
|
||||
|
||||
logger.info("🛠️ Létrehozom a finance.user_subscriptions táblát...")
|
||||
await db.execute(
|
||||
text("""
|
||||
CREATE TABLE finance.user_subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
|
||||
tier_id INTEGER NOT NULL REFERENCES system.subscription_tiers(id),
|
||||
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
valid_until TIMESTAMPTZ,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
""")
|
||||
)
|
||||
# Index a gyors lekérdezéshez
|
||||
await db.execute(
|
||||
text("""
|
||||
CREATE INDEX idx_user_subscriptions_user_id
|
||||
ON finance.user_subscriptions(user_id)
|
||||
""")
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("✅ finance.user_subscriptions tábla létrehozva.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C) Adatkonverzió
|
||||
# ---------------------------------------------------------------------------
|
||||
async def _migrate_organizations(db) -> None:
|
||||
"""Szervezetek subscription_plan mezőjének átvezetése org_subscriptions-ba."""
|
||||
logger.info("=" * 60)
|
||||
logger.info("Szervezetek migrálása...")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 1. Lekérdezzük az összes szervezetet, aminek nincs még org_subscription-e
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT o.id, o.subscription_plan
|
||||
FROM fleet.organizations o
|
||||
WHERE o.is_deleted = FALSE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM finance.org_subscriptions os
|
||||
WHERE os.org_id = o.id
|
||||
)
|
||||
ORDER BY o.id
|
||||
""")
|
||||
)
|
||||
orgs = result.all()
|
||||
logger.info("📊 %d szervezet vár migrálásra.", len(orgs))
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
for org in orgs:
|
||||
tier_id = _resolve_tier_id(org.subscription_plan)
|
||||
if tier_id is None:
|
||||
logger.warning("⚠️ Org ID=%d: plan='%s' nem feloldható, kihagyva.", org.id, org.subscription_plan)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
sub = OrganizationSubscription(
|
||||
org_id=org.id,
|
||||
tier_id=tier_id,
|
||||
valid_from=datetime.now(timezone.utc),
|
||||
valid_until=None,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sub)
|
||||
inserted += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info("✅ %d org_subscription beszúrva. (%d kihagyva)", inserted, skipped)
|
||||
|
||||
|
||||
async def _migrate_users(db) -> None:
|
||||
"""Felhasználók subscription_plan mezőjének átvezetése user_subscriptions-ba."""
|
||||
logger.info("=" * 60)
|
||||
logger.info("Felhasználók migrálása...")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 1. Lekérdezzük az összes aktív felhasználót, aminek nincs még user_subscription-e
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT u.id, u.subscription_plan
|
||||
FROM identity.users u
|
||||
WHERE u.is_deleted = FALSE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM finance.user_subscriptions us
|
||||
WHERE us.user_id = u.id
|
||||
)
|
||||
ORDER BY u.id
|
||||
""")
|
||||
)
|
||||
users = result.all()
|
||||
logger.info("📊 %d felhasználó vár migrálásra.", len(users))
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
for user in users:
|
||||
tier_id = _resolve_tier_id(user.subscription_plan)
|
||||
if tier_id is None:
|
||||
logger.warning("⚠️ User ID=%d: plan='%s' nem feloldható, kihagyva.", user.id, user.subscription_plan)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO finance.user_subscriptions
|
||||
(user_id, tier_id, valid_from, is_active, created_at)
|
||||
VALUES
|
||||
(:uid, :tid, NOW(), TRUE, NOW())
|
||||
"""),
|
||||
{"uid": user.id, "tid": tier_id},
|
||||
)
|
||||
inserted += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info("✅ %d user_subscription beszúrva. (%d kihagyva)", inserted, skipped)
|
||||
|
||||
|
||||
async def _verify_migration(db) -> None:
|
||||
"""Ellenőrzi a migráció sikerességét."""
|
||||
logger.info("=" * 60)
|
||||
logger.info("🔍 Ellenőrzés")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Tiers
|
||||
result = await db.execute(
|
||||
text("SELECT id, name FROM system.subscription_tiers ORDER BY id")
|
||||
)
|
||||
tiers = result.all()
|
||||
logger.info("📋 Tiers (%d db):", len(tiers))
|
||||
for t in tiers:
|
||||
logger.info(" ID=%d name=%s", t.id, t.name)
|
||||
|
||||
# Org subscriptions
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT COUNT(*) as cnt,
|
||||
COALESCE(t.name, 'N/A') as tier_name
|
||||
FROM finance.org_subscriptions os
|
||||
LEFT JOIN system.subscription_tiers t ON t.id = os.tier_id
|
||||
GROUP BY t.name
|
||||
ORDER BY t.name
|
||||
""")
|
||||
)
|
||||
rows = result.all()
|
||||
total_org = sum(r.cnt for r in rows)
|
||||
logger.info("📊 Org subscriptions (%d db):", total_org)
|
||||
for r in rows:
|
||||
logger.info(" %s: %d", r.tier_name, r.cnt)
|
||||
|
||||
# User subscriptions
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT COUNT(*) as cnt,
|
||||
COALESCE(t.name, 'N/A') as tier_name
|
||||
FROM finance.user_subscriptions us
|
||||
LEFT JOIN system.subscription_tiers t ON t.id = us.tier_id
|
||||
GROUP BY t.name
|
||||
ORDER BY t.name
|
||||
""")
|
||||
)
|
||||
rows = result.all()
|
||||
total_user = sum(r.cnt for r in rows)
|
||||
logger.info("📊 User subscriptions (%d db):", total_user)
|
||||
for r in rows:
|
||||
logger.info(" %s: %d", r.tier_name, r.cnt)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("✅ Migráció befejeződött!")
|
||||
logger.info(" Összes org subscription: %d", total_org)
|
||||
logger.info(" Összes user subscription: %d", total_user)
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
async def migrate():
|
||||
logger.info("=" * 60)
|
||||
logger.info("📦 Nagy Előfizetés Migráció")
|
||||
logger.info("=" * 60)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# A) corp_free_v1 létrehozása
|
||||
logger.info("\n[1/5] corp_free_v1 csomag ellenőrzése...")
|
||||
await _ensure_corp_free_v1(db)
|
||||
|
||||
# Tier map betöltése
|
||||
logger.info("\n[2/5] Tier-ek betöltése...")
|
||||
await _load_tier_map(db)
|
||||
|
||||
# B) user_subscriptions tábla létrehozása
|
||||
logger.info("\n[3/5] user_subscriptions tábla ellenőrzése...")
|
||||
await _ensure_user_subscriptions_table(db)
|
||||
|
||||
# C) Adatkonverzió
|
||||
logger.info("\n[4/5] Adatkonverzió...")
|
||||
await _migrate_organizations(db)
|
||||
await _migrate_users(db)
|
||||
|
||||
# Ellenőrzés
|
||||
logger.info("\n[5/5] Ellenőrzés...")
|
||||
await _verify_migration(db)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(migrate())
|
||||
269
backend/app/scripts/seed_packages.py
Normal file
269
backend/app/scripts/seed_packages.py
Normal file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed szkript a jövőbiztos SaaS csomag-architektúra 1. fázisához.
|
||||
Létrehozza a 6 alap subscription tier-t a system.subscription_tiers táblában.
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/seed_packages.py
|
||||
|
||||
Architektúra:
|
||||
A csomagok rules JSONB oszlopa egy szabványos struktúrát követ:
|
||||
{
|
||||
"type": "private" | "corporate",
|
||||
"pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
|
||||
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
|
||||
"entitlements": ["SRV_..."],
|
||||
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
|
||||
}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from sqlalchemy import select, delete
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("Seed-Packages")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Csomagdefiníciók
|
||||
# ---------------------------------------------------------------------------
|
||||
PACKAGES = [
|
||||
{
|
||||
"name": "private_free_v1",
|
||||
"rules": {
|
||||
"type": "private",
|
||||
"display_name": "Privát Ingyenes",
|
||||
"pricing": {
|
||||
"monthly_price": 0.0,
|
||||
"yearly_price": 0.0,
|
||||
"currency": "EUR",
|
||||
"credit_price": 0,
|
||||
},
|
||||
"allowances": {
|
||||
"max_vehicles": 1,
|
||||
"max_garages": 1,
|
||||
"monthly_free_credits": 0,
|
||||
},
|
||||
"entitlements": [],
|
||||
"affiliate": {
|
||||
"commission_rate_percent": 0,
|
||||
"referral_bonus_credits": 0,
|
||||
},
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
{
|
||||
"name": "private_pro_v1",
|
||||
"rules": {
|
||||
"type": "private",
|
||||
"display_name": "Privát Pro",
|
||||
"pricing": {
|
||||
"monthly_price": 4.99,
|
||||
"yearly_price": 49.99,
|
||||
"currency": "EUR",
|
||||
"credit_price": 500,
|
||||
},
|
||||
"allowances": {
|
||||
"max_vehicles": 3,
|
||||
"max_garages": 1,
|
||||
"monthly_free_credits": 50,
|
||||
},
|
||||
"entitlements": ["SRV_DATA_EXPORT"],
|
||||
"affiliate": {
|
||||
"commission_rate_percent": 10,
|
||||
"referral_bonus_credits": 25,
|
||||
},
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
{
|
||||
"name": "private_vip_v1",
|
||||
"rules": {
|
||||
"type": "private",
|
||||
"display_name": "Privát VIP",
|
||||
"pricing": {
|
||||
"monthly_price": 9.99,
|
||||
"yearly_price": 99.99,
|
||||
"currency": "EUR",
|
||||
"credit_price": 1200,
|
||||
},
|
||||
"allowances": {
|
||||
"max_vehicles": 10,
|
||||
"max_garages": 1,
|
||||
"monthly_free_credits": 150,
|
||||
},
|
||||
"entitlements": ["SRV_DATA_EXPORT", "SRV_AI_UPLOAD"],
|
||||
"affiliate": {
|
||||
"commission_rate_percent": 15,
|
||||
"referral_bonus_credits": 50,
|
||||
},
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
{
|
||||
"name": "corp_premium_v1",
|
||||
"rules": {
|
||||
"type": "corporate",
|
||||
"display_name": "Céges Prémium",
|
||||
"pricing": {
|
||||
"monthly_price": 29.99,
|
||||
"yearly_price": 299.99,
|
||||
"currency": "EUR",
|
||||
"credit_price": 3000,
|
||||
},
|
||||
"allowances": {
|
||||
"max_vehicles": 20,
|
||||
"max_garages": 3,
|
||||
"monthly_free_credits": 300,
|
||||
},
|
||||
"entitlements": ["SRV_DATA_EXPORT", "SRV_AI_UPLOAD"],
|
||||
"affiliate": {
|
||||
"commission_rate_percent": 15,
|
||||
"referral_bonus_credits": 100,
|
||||
},
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
{
|
||||
"name": "corp_premium_plus_v1",
|
||||
"rules": {
|
||||
"type": "corporate",
|
||||
"display_name": "Céges Prémium Plus",
|
||||
"pricing": {
|
||||
"monthly_price": 59.99,
|
||||
"yearly_price": 599.99,
|
||||
"currency": "EUR",
|
||||
"credit_price": 6000,
|
||||
},
|
||||
"allowances": {
|
||||
"max_vehicles": 50,
|
||||
"max_garages": 10,
|
||||
"monthly_free_credits": 800,
|
||||
},
|
||||
"entitlements": [
|
||||
"SRV_DATA_EXPORT",
|
||||
"SRV_AI_UPLOAD",
|
||||
"SRV_ACCOUNTING_SYNC",
|
||||
],
|
||||
"affiliate": {
|
||||
"commission_rate_percent": 20,
|
||||
"referral_bonus_credits": 250,
|
||||
},
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
{
|
||||
"name": "corp_vip_v1",
|
||||
"rules": {
|
||||
"type": "corporate",
|
||||
"display_name": "Céges VIP",
|
||||
"pricing": {
|
||||
"monthly_price": 149.99,
|
||||
"yearly_price": 1499.99,
|
||||
"currency": "EUR",
|
||||
"credit_price": 15000,
|
||||
},
|
||||
"allowances": {
|
||||
"max_vehicles": 200,
|
||||
"max_garages": 50,
|
||||
"monthly_free_credits": 2500,
|
||||
},
|
||||
"entitlements": [
|
||||
"SRV_DATA_EXPORT",
|
||||
"SRV_AI_UPLOAD",
|
||||
"SRV_ACCOUNTING_SYNC",
|
||||
"SRV_API_ACCESS",
|
||||
],
|
||||
"affiliate": {
|
||||
"commission_rate_percent": 25,
|
||||
"referral_bonus_credits": 500,
|
||||
},
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed():
|
||||
logger.info("=" * 60)
|
||||
logger.info("Seed Packages - Advanced Subscription Tiers (Phase 1)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 1. Töröljük a meglévő rekordokat (tiszta lap)
|
||||
logger.info("Törlöm a meglévő subscription_tiers rekordokat...")
|
||||
await db.execute(delete(SubscriptionTier))
|
||||
await db.commit()
|
||||
logger.info("✅ Meglévő rekordok törölve.")
|
||||
|
||||
# 2. Beszúrjuk a 6 új csomagot
|
||||
inserted_count = 0
|
||||
for pkg in PACKAGES:
|
||||
tier = SubscriptionTier(
|
||||
name=pkg["name"],
|
||||
rules=pkg["rules"],
|
||||
is_custom=pkg["is_custom"],
|
||||
)
|
||||
db.add(tier)
|
||||
inserted_count += 1
|
||||
logger.info(f" ➕ Hozzáadva: {pkg['name']}")
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ {inserted_count} csomag sikeresen beszúrva.")
|
||||
|
||||
# 3. Visszaigazolás: listázzuk ki az összes rekordot
|
||||
logger.info("-" * 60)
|
||||
logger.info("Visszaigazolás - system.subscription_tiers tartalma:")
|
||||
logger.info("-" * 60)
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionTier).order_by(SubscriptionTier.id)
|
||||
)
|
||||
tiers = result.scalars().all()
|
||||
|
||||
for tier in tiers:
|
||||
rules_json = json.dumps(tier.rules, indent=2, ensure_ascii=False)
|
||||
logger.info(f"\n ID: {tier.id}")
|
||||
logger.info(f" Name: {tier.name}")
|
||||
logger.info(f" Is Custom: {tier.is_custom}")
|
||||
logger.info(f" Rules:\n{rules_json}")
|
||||
logger.info(" " + "-" * 40)
|
||||
|
||||
logger.info(f"\n✅ Összesen {len(tiers)} aktív csomag a rendszerben.")
|
||||
|
||||
# 4. JSONB példa kiírása
|
||||
if tiers:
|
||||
sample = tiers[0]
|
||||
logger.info("=" * 60)
|
||||
logger.info("JSONB PÉLDA (private_free_v1):")
|
||||
logger.info("=" * 60)
|
||||
logger.info(json.dumps(sample.rules, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
94
backend/app/scripts/seed_services.py
Normal file
94
backend/app/scripts/seed_services.py
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Service Finder - ServiceCatalog Seed Script
|
||||
=============================================
|
||||
Aszinkron seed szkript a system.service_catalog tábla feltöltéséhez.
|
||||
Törli a meglévő adatokat, majd beszúrja a 3 alap szolgáltatást.
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/seed_services.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Projekt gyökér hozzáadása a Python path-hoz
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.core_logic import ServiceCatalog
|
||||
|
||||
|
||||
SERVICES = [
|
||||
{
|
||||
"service_code": "SRV_DATA_EXPORT",
|
||||
"name": "Adat Export",
|
||||
"description": "PDF és Excel adatexport funkciók",
|
||||
"credit_cost": 0,
|
||||
},
|
||||
{
|
||||
"service_code": "SRV_AI_UPLOAD",
|
||||
"name": "AI Számlafeldolgozás",
|
||||
"description": "Dokumentumok és számlák AI alapú beolvasása",
|
||||
"credit_cost": 50,
|
||||
},
|
||||
{
|
||||
"service_code": "SRV_DIGITAL_BOOK",
|
||||
"name": "Digitális Szervizkönyv",
|
||||
"description": "Idegen járművek szerviztörténetének lekérdezése",
|
||||
"credit_cost": 150,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_services():
|
||||
print("=" * 70)
|
||||
print(" ServiceCatalog Seed Script")
|
||||
print("=" * 70)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 1. Tábla tartalmának törlése
|
||||
print("\n🧹 Törlés: system.service_catalog összes rekordja...")
|
||||
await db.execute(text("DELETE FROM system.service_catalog"))
|
||||
await db.commit()
|
||||
print(" ✅ Törölve.")
|
||||
|
||||
# 2. Szolgáltatások beszúrása
|
||||
print("\n📦 Szolgáltatások beszúrása:")
|
||||
for svc in SERVICES:
|
||||
stmt = text("""
|
||||
INSERT INTO system.service_catalog (service_code, name, description, credit_cost, is_active)
|
||||
VALUES (:service_code, :name, :description, :credit_cost, TRUE)
|
||||
""")
|
||||
await db.execute(stmt, svc)
|
||||
print(f" ✅ {svc['service_code']:20s} | {svc['name']:25s} | {svc['credit_cost']:>4} kredit")
|
||||
|
||||
await db.commit()
|
||||
print("\n ✅ Minden rekord beszúrva.")
|
||||
|
||||
# 3. Lekérdezés - bizonyíték
|
||||
print("\n" + "=" * 70)
|
||||
print(" 📋 LEKÉRDEZÉS - system.service_catalog tartalma")
|
||||
print("=" * 70)
|
||||
result = await db.execute(
|
||||
text("SELECT id, service_code, name, description, credit_cost, is_active FROM system.service_catalog ORDER BY id")
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
if not rows:
|
||||
print("\n❌ NINCSENEK REKORDOK a táblában!")
|
||||
else:
|
||||
print(f"\n{'ID':>4} | {'Kód':20s} | {'Név':25s} | {'Leírás':45s} | {'Ár':>5s} | {'Aktív':>5s}")
|
||||
print("-" * 110)
|
||||
for row in rows:
|
||||
print(f"{row.id:>4} | {row.service_code:20s} | {row.name:25s} | {(row.description or ''):45s} | {row.credit_cost:>5} | {'Igen' if row.is_active else 'Nem'}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f" Összesen: {len(rows)} rekord a system.service_catalog táblában.")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_services())
|
||||
@@ -18,15 +18,7 @@ from app.database import Base
|
||||
from app.core.config import settings
|
||||
|
||||
def dynamic_import_models():
|
||||
models_dir = Path(__file__).parent.parent / "models"
|
||||
for py_file in models_dir.glob("*.py"):
|
||||
if py_file.name == "__init__.py": continue
|
||||
module_name = f"app.models.{py_file.stem}"
|
||||
try:
|
||||
importlib.import_module(module_name)
|
||||
print(f"✅ Imported {module_name}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not import {module_name}: {e}")
|
||||
"""Modellek betöltése a Metadata feltöltéséhez. Egyszerű import az __init__.py-n keresztül."""
|
||||
import app.models
|
||||
print(f"📦 Total tables in Base.metadata: {len(Base.metadata.tables)}")
|
||||
|
||||
|
||||
@@ -228,12 +228,34 @@ class AssetService:
|
||||
# --- MATCHER INTEGRÁCIÓ (HA NINCS CATALOG_ID DE VANNAK ADATOK) ---
|
||||
if not new_asset.catalog_id and new_asset.brand and new_asset.model:
|
||||
from app.services.asset_matcher_service import AssetMatcherService
|
||||
from app.models.vehicle.asset import AssetCatalog
|
||||
print(f"DEBUG: Matcher args: make={new_asset.brand}, model={new_asset.model}, year={new_asset.year_of_manufacture}")
|
||||
matched_result = await AssetMatcherService.find_best_match(db, new_asset)
|
||||
if matched_result:
|
||||
matched_def, conf = matched_result
|
||||
if matched_def:
|
||||
new_asset.catalog_id = matched_def.id
|
||||
# FIX: Asset.catalog_id FK references vehicle.vehicle_catalog.id (AssetCatalog),
|
||||
# NOT vehicle.vehicle_model_definitions.id (VehicleModelDefinition).
|
||||
# Look up or create the AssetCatalog entry for this definition.
|
||||
catalog_stmt = select(AssetCatalog).where(
|
||||
AssetCatalog.master_definition_id == matched_def.id
|
||||
).limit(1)
|
||||
catalog_entry = (await db.execute(catalog_stmt)).scalar_one_or_none()
|
||||
if not catalog_entry:
|
||||
# Create a new AssetCatalog entry from the matched definition
|
||||
catalog_entry = AssetCatalog(
|
||||
master_definition_id=matched_def.id,
|
||||
make=matched_def.make,
|
||||
model=matched_def.marketing_name,
|
||||
year_from=matched_def.year_from,
|
||||
year_to=matched_def.year_to,
|
||||
fuel_type=matched_def.fuel_type,
|
||||
power_kw=matched_def.power_kw if matched_def.power_kw and matched_def.power_kw > 0 else None,
|
||||
engine_capacity=matched_def.engine_capacity if matched_def.engine_capacity and matched_def.engine_capacity > 0 else None,
|
||||
)
|
||||
db.add(catalog_entry)
|
||||
await db.flush()
|
||||
new_asset.catalog_id = catalog_entry.id
|
||||
await AssetMatcherService.enrich_asset_from_definition(db, new_asset, matched_def, conf)
|
||||
await db.flush()
|
||||
|
||||
@@ -348,9 +370,15 @@ class AssetService:
|
||||
|
||||
@staticmethod
|
||||
async def get_models(db: AsyncSession, make: str, vehicle_class: str = None) -> List[str]:
|
||||
"""Get all distinct models for a given make, optionally filtered by vehicle_class."""
|
||||
"""Get all distinct models for a given make, optionally filtered by vehicle_class.
|
||||
|
||||
Fuzzy matching: removes spaces and lowercases both the query and stored values
|
||||
so that 'CB 1000' matches 'CB1000'.
|
||||
"""
|
||||
stmt = select(distinct(VehicleModelDefinition.marketing_name)).where(
|
||||
VehicleModelDefinition.make == make
|
||||
func.replace(func.lower(VehicleModelDefinition.make), ' ', '').ilike(
|
||||
f'%{make.replace(" ", "").lower()}%'
|
||||
)
|
||||
)
|
||||
if vehicle_class:
|
||||
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
|
||||
@@ -384,6 +412,78 @@ class AssetService:
|
||||
engines = result.scalars().all()
|
||||
return engines
|
||||
|
||||
# --- CASCADING CATALOG METHODS (for wizard autocomplete) ---
|
||||
@staticmethod
|
||||
async def get_catalog_brands(db: AsyncSession, vehicle_class: Optional[str] = None, query: Optional[str] = None) -> List[str]:
|
||||
"""Get all distinct brands (makes) from vehicle_model_definitions, with optional class filter and search query.
|
||||
|
||||
Fuzzy matching: removes spaces and lowercases both the query and stored values
|
||||
so that 'BMW' matches 'bmw' and 'CB 1000' matches 'CB1000'.
|
||||
"""
|
||||
stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make)
|
||||
if vehicle_class:
|
||||
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
|
||||
if query:
|
||||
clean_query = query.replace(' ', '').lower()
|
||||
stmt = stmt.where(
|
||||
func.replace(func.lower(VehicleModelDefinition.make), ' ', '').ilike(f'%{clean_query}%')
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
brands = result.scalars().all()
|
||||
return [b for b in brands if b]
|
||||
|
||||
@staticmethod
|
||||
async def get_catalog_models(db: AsyncSession, brand: str, vehicle_class: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get all distinct models for a given brand, returning id and marketing_name."""
|
||||
stmt = select(
|
||||
VehicleModelDefinition.id,
|
||||
VehicleModelDefinition.marketing_name
|
||||
).where(
|
||||
VehicleModelDefinition.make == brand
|
||||
).distinct(VehicleModelDefinition.marketing_name).order_by(VehicleModelDefinition.marketing_name)
|
||||
if vehicle_class:
|
||||
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
return [{"id": r.id, "name": r.marketing_name} for r in rows if r.marketing_name]
|
||||
|
||||
@staticmethod
|
||||
async def get_catalog_years(db: AsyncSession, brand: str, model: str) -> List[int]:
|
||||
"""Get all distinct years for a given brand and model."""
|
||||
stmt = select(distinct(VehicleModelDefinition.year_from)).where(
|
||||
VehicleModelDefinition.make == brand,
|
||||
VehicleModelDefinition.marketing_name == model,
|
||||
VehicleModelDefinition.year_from > 0
|
||||
).order_by(VehicleModelDefinition.year_from.desc())
|
||||
result = await db.execute(stmt)
|
||||
years = result.scalars().all()
|
||||
return [y for y in years if y]
|
||||
|
||||
@staticmethod
|
||||
async def get_catalog_trims(db: AsyncSession, brand: str, model: str, year: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""Get all trim/variant details for a given brand, model, and optional year."""
|
||||
stmt = select(VehicleModelDefinition).where(
|
||||
VehicleModelDefinition.make == brand,
|
||||
VehicleModelDefinition.marketing_name == model
|
||||
)
|
||||
if year and year > 0:
|
||||
stmt = stmt.where(VehicleModelDefinition.year_from == year)
|
||||
stmt = stmt.order_by(VehicleModelDefinition.trim_level, VehicleModelDefinition.engine_capacity)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"trim_level": r.trim_level or '',
|
||||
"engine_capacity": r.engine_capacity or 0,
|
||||
"power_kw": r.power_kw or 0,
|
||||
"fuel_type": r.fuel_type,
|
||||
"year_from": r.year_from,
|
||||
"body_type": r.body_type or '',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
|
||||
"""
|
||||
@@ -596,15 +696,13 @@ class AssetService:
|
||||
user_badge = UserBadge(
|
||||
user_id=user_id,
|
||||
badge_id=badge.id,
|
||||
awarded_at=datetime.utcnow()
|
||||
earned_at=datetime.utcnow()
|
||||
)
|
||||
db.add(user_badge)
|
||||
await db.flush()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Awarded 'First Car' badge to user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error awarding first car badge: {e}")
|
||||
# Don't raise the error - badge awarding shouldn't break vehicle creation
|
||||
@@ -9,8 +9,8 @@ from sqlalchemy.orm import joinedload
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
|
||||
from app.models import UserStats
|
||||
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet, OneTimePassword
|
||||
from app.models import UserStats, LogSeverity
|
||||
from app.models.marketplace import Organization, OrganizationMember, OrgType, Branch
|
||||
from app.schemas.auth import UserLiteRegister, UserKYCComplete
|
||||
from app.core.security import get_password_hash, verify_password, generate_secure_slug
|
||||
@@ -146,7 +146,7 @@ class AuthService:
|
||||
# Sentinel Audit Log
|
||||
await security_service.log_event(
|
||||
db, user_id=new_user.id, action="USER_REGISTER_LITE",
|
||||
severity="INFO", target_type="User", target_id=str(new_user.id),
|
||||
severity=LogSeverity.info, target_type="User", target_id=str(new_user.id),
|
||||
new_data={"email": user_in.email}
|
||||
)
|
||||
|
||||
@@ -285,8 +285,8 @@ class AuthService:
|
||||
if kyc_in.region_code:
|
||||
user.region_code = kyc_in.region_code
|
||||
|
||||
# Gamification XP jóváírás
|
||||
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION")
|
||||
# Gamification XP jóváírás (commit=False, mert a külső metódus kezeli a tranzakciót)
|
||||
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION", commit=False)
|
||||
|
||||
# P2P Referral XP a meghívónak (ha van referred_by_id)
|
||||
if user.referred_by_id:
|
||||
@@ -295,7 +295,8 @@ class AuthService:
|
||||
db,
|
||||
user_id=user.referred_by_id,
|
||||
amount=int(p2p_xp),
|
||||
reason="P2P_REFERRAL_SUCCESS"
|
||||
reason="P2P_REFERRAL_SUCCESS",
|
||||
commit=False
|
||||
)
|
||||
logger.info(f"P2P XP ({p2p_xp}) awarded to referrer ID {user.referred_by_id} for user {user.id}")
|
||||
|
||||
@@ -341,11 +342,15 @@ class AuthService:
|
||||
|
||||
user.is_active = True
|
||||
|
||||
# Activate person
|
||||
person_stmt = select(Person).where(Person.user_id == user.id)
|
||||
person = (await db.execute(person_stmt)).scalar_one_or_none()
|
||||
if person:
|
||||
person.is_active = True
|
||||
# Activate person - FIX: Use User.person_id instead of Person.user_id
|
||||
# Person.user_id is a separate FK that may be NULL
|
||||
if user.person_id:
|
||||
person_stmt = select(Person).where(Person.id == user.person_id)
|
||||
person = (await db.execute(person_stmt)).scalar_one_or_none()
|
||||
if person:
|
||||
person.is_active = True
|
||||
# Also set the back-reference so Person.user_id is consistent
|
||||
person.user_id = user.id
|
||||
|
||||
await db.commit()
|
||||
return user # Return the activated User object
|
||||
@@ -379,20 +384,23 @@ class AuthService:
|
||||
))
|
||||
|
||||
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
|
||||
logger.info(f"Email küldés indítása (password reset) ide: {email}...")
|
||||
result = await email_manager.send_email(
|
||||
recipient=email, template_key="pwd_reset",
|
||||
variables={"link": link}, lang=user.preferred_language
|
||||
)
|
||||
logger.info(f"Email küldés eredménye (password reset): {result}")
|
||||
|
||||
# Ha az email küldés hibát dobott, ne titkoljuk el
|
||||
if result.get("status") == "error":
|
||||
logger.error(f"Password reset email FAILED for {email}: {result.get('message')}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Email küldési hiba: {result.get('message')}"
|
||||
# Mock email sending: log the reset link to console instead of sending real email
|
||||
logger.info(f"*** MOCK EMAIL *** Password reset for {email}")
|
||||
logger.info(f"*** MOCK EMAIL *** Reset link: {link}")
|
||||
logger.info(f"*** MOCK EMAIL *** Token: {token_val}")
|
||||
|
||||
# Try to send real email, but don't crash if SMTP is unavailable
|
||||
try:
|
||||
result = await email_manager.send_email(
|
||||
recipient=email, template_key="pwd_reset",
|
||||
variables={"link": link}, lang=user.preferred_language
|
||||
)
|
||||
logger.info(f"Email küldés eredménye (password reset): {result}")
|
||||
if result and result.get("status") == "error":
|
||||
logger.warning(f"Password reset email sending failed (non-critical): {result.get('message')}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Password reset email sending exception (non-critical): {e}")
|
||||
|
||||
await db.commit()
|
||||
return "success"
|
||||
@@ -424,7 +432,177 @@ class AuthService:
|
||||
return True
|
||||
except HTTPException:
|
||||
raise
|
||||
except: return False
|
||||
except Exception as e:
|
||||
logger.error(f"Password reset error: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def _generate_otp_code() -> str:
|
||||
"""Generate a random 6-digit OTP code."""
|
||||
import random
|
||||
return f"{random.randint(0, 999999):06d}"
|
||||
|
||||
@staticmethod
|
||||
async def request_account_restore(db: AsyncSession, email: str):
|
||||
"""
|
||||
30 napos fiókvisszaállítás: OTP kérés.
|
||||
Megkeresi a törölt felhasználót az eredeti email alapján.
|
||||
Ha 30 napon belül van a törlés, generál 6-jegyű kódot.
|
||||
Ha 30 napon TÚL van, jelzi, hogy új regisztráció szükséges.
|
||||
"""
|
||||
try:
|
||||
# Search for deleted user by checking if email exists in deleted prefix pattern
|
||||
stmt = select(User).where(
|
||||
User.email.like(f"%{email}"),
|
||||
User.is_deleted == True
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return {"status": "not_found", "message": "Nem található törölt fiók ezzel az email címmel."}
|
||||
|
||||
# Check if within 30-day window
|
||||
if user.deleted_at:
|
||||
days_since_deletion = (datetime.now(timezone.utc) - user.deleted_at).days
|
||||
else:
|
||||
days_since_deletion = 0
|
||||
|
||||
if days_since_deletion > 30:
|
||||
return {
|
||||
"status": "expired",
|
||||
"message": "A fiók véglegesen megsemmisült. Kérjük, regisztráljon újra ugyanazzal az email címmel.",
|
||||
"can_re_register": True,
|
||||
"original_email": email
|
||||
}
|
||||
|
||||
# Generate 6-digit OTP code
|
||||
code = await AuthService._generate_otp_code()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
|
||||
# Store OTP
|
||||
otp = OneTimePassword(
|
||||
email=email,
|
||||
code=code,
|
||||
otp_type="restore",
|
||||
extra_data={"user_id": user.id},
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(otp)
|
||||
await db.commit()
|
||||
|
||||
# Mock email sending: log the OTP code to console instead of sending real email
|
||||
logger.info(f"*** MOCK EMAIL *** OTP Code for {email}: {code}")
|
||||
logger.info(f"*** MOCK EMAIL *** Expires at: {expires_at.isoformat()}")
|
||||
|
||||
return {
|
||||
"status": "otp_sent",
|
||||
"message": "Egy 6-jegyű visszaállítási kódot küldtünk az email címére.",
|
||||
"expires_at": expires_at.isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Account restore request error for {email}: {e}")
|
||||
return {"status": "error", "message": "Hiba történt a visszaállítási kód generálása során."}
|
||||
|
||||
@staticmethod
|
||||
async def verify_account_restore(db: AsyncSession, email: str, code: str):
|
||||
"""
|
||||
OTP kód ellenőrzése és fiók visszaállítása.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Find valid OTP
|
||||
stmt = select(OneTimePassword).where(
|
||||
OneTimePassword.email == email,
|
||||
OneTimePassword.code == code,
|
||||
OneTimePassword.otp_type == "restore",
|
||||
OneTimePassword.is_used == False,
|
||||
OneTimePassword.expires_at > now
|
||||
).order_by(OneTimePassword.created_at.desc()).limit(1)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
otp = result.scalar_one_or_none()
|
||||
|
||||
if not otp:
|
||||
return {"status": "invalid", "message": "Érvénytelen vagy lejárt kód."}
|
||||
|
||||
# Mark OTP as used
|
||||
otp.is_used = True
|
||||
|
||||
# Find the deleted user
|
||||
user_id = otp.extra_data.get("user_id") if otp.extra_data else None
|
||||
if not user_id:
|
||||
return {"status": "error", "message": "Hiba a fiók azonosításában."}
|
||||
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return {"status": "error", "message": "Felhasználó nem található."}
|
||||
|
||||
# Restore the account
|
||||
user.is_deleted = False
|
||||
user.is_active = True
|
||||
user.deleted_at = None
|
||||
|
||||
# Restore original email (remove deleted_ prefix)
|
||||
# The email format is: deleted_{id}_{timestamp}_{original_email}
|
||||
if user.email.startswith("deleted_"):
|
||||
parts = user.email.split("_", 3)
|
||||
if len(parts) == 4:
|
||||
user.email = parts[3]
|
||||
|
||||
await security_service.log_event(
|
||||
db, user_id=user.id, action="USER_ACCOUNT_RESTORE",
|
||||
severity=LogSeverity.info, target_type="User", target_id=str(user.id),
|
||||
new_data={"restored_via": "otp", "email": user.email}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "message": "Fiók sikeresen visszaállítva."}
|
||||
|
||||
@staticmethod
|
||||
async def check_is_last_admin(db: AsyncSession, user_id: int) -> bool:
|
||||
"""
|
||||
Ellenőrzi, hogy a felhasználó az egyetlen aktív admin bármelyik szervezetében.
|
||||
Visszaadja: is_last_admin: bool
|
||||
"""
|
||||
try:
|
||||
from app.models.marketplace.organization import OrganizationMember, OrgUserRole
|
||||
|
||||
# Find organizations where user is ADMIN or OWNER
|
||||
stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER])
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
org_ids = [row[0] for row in result.all()]
|
||||
|
||||
if not org_ids:
|
||||
return False
|
||||
|
||||
# For each org, check if there are other active admins/owners
|
||||
for org_id in org_ids:
|
||||
stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER]),
|
||||
OrganizationMember.user_id != user_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
other_admins = result.scalars().all()
|
||||
|
||||
if not other_admins:
|
||||
# This org has no other admin - user is last admin here
|
||||
return True
|
||||
|
||||
return False
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"check_is_last_admin error: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def resend_verification(db: AsyncSession, email: str):
|
||||
@@ -479,20 +657,44 @@ class AuthService:
|
||||
|
||||
@staticmethod
|
||||
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
|
||||
""" Felhasználó törlése (Soft-Delete) auditálással. """
|
||||
"""
|
||||
Felhasználó törlése (Soft-Delete) auditálással, Person-preserving logikával.
|
||||
|
||||
Üzleti szabályok:
|
||||
1. User: is_active=False, is_deleted=True, deleted_at=utcnow
|
||||
2. Email átírás: deleted_{id}_{timestamp}_{original_email}
|
||||
3. Person rekordot NEM bántja (megőrizzük a jövőbeli újraaktiváláshoz)
|
||||
4. Wallet és Gamification adatok megőrzése (inaktív user miatt freeze)
|
||||
5. Refresh token-ek érvénytelenítése (JWT-alapú, így a deleted_at middleware ellenőrzi)
|
||||
"""
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not user or user.is_deleted: return False
|
||||
if not user or user.is_deleted:
|
||||
return False
|
||||
|
||||
# 1. Anonymize email with timestamp
|
||||
old_email = user.email
|
||||
user.email = f"deleted_{user.id}_{datetime.now().strftime('%Y%m%d')}_{old_email}"
|
||||
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
|
||||
user.email = f"deleted_{user.id}_{timestamp}_{old_email}"
|
||||
|
||||
# 2. Set soft-delete flags
|
||||
user.is_deleted = True
|
||||
user.is_active = False
|
||||
|
||||
user.deleted_at = datetime.now(timezone.utc)
|
||||
|
||||
# 3. CRITICAL: Do NOT touch Person record
|
||||
# Person.is_active stays as-is, Person data stays intact
|
||||
# This allows future re-activation with the same identity
|
||||
|
||||
# 4. Security audit log with deleted_at info
|
||||
await security_service.log_event(
|
||||
db, user_id=actor_id, action="USER_SOFT_DELETE",
|
||||
severity="WARNING", target_type="User", target_id=str(user_id),
|
||||
new_data={"reason": reason}
|
||||
severity=LogSeverity.warning, target_type="User", target_id=str(user_id),
|
||||
new_data={
|
||||
"reason": reason,
|
||||
"deleted_at": str(user.deleted_at),
|
||||
"person_preserved": True
|
||||
}
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
return True
|
||||
|
||||
@@ -18,28 +18,40 @@ class GamificationService:
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0):
|
||||
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz. """
|
||||
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True):
|
||||
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz.
|
||||
|
||||
Args:
|
||||
commit: If True (default), commits the transaction internally.
|
||||
Set to False when called from within a larger transaction
|
||||
(e.g., from AuthService.complete_kyc).
|
||||
"""
|
||||
service = GamificationService()
|
||||
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason)
|
||||
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit)
|
||||
|
||||
async def process_activity(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
xp_amount: int,
|
||||
social_amount: int,
|
||||
reason: str,
|
||||
is_penalty: bool = False
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
xp_amount: int,
|
||||
social_amount: int,
|
||||
reason: str,
|
||||
is_penalty: bool = False,
|
||||
commit: bool = True
|
||||
):
|
||||
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés. """
|
||||
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés.
|
||||
|
||||
Args:
|
||||
commit: If True (default), commits the transaction internally.
|
||||
Set to False when called from within a larger transaction.
|
||||
"""
|
||||
try:
|
||||
# 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE
|
||||
# Minden paraméter az admin felületről módosítható JSON-ként
|
||||
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default={
|
||||
"xp_logic": {"base_xp": 500, "exponent": 1.5},
|
||||
"penalty_logic": {
|
||||
"recovery_rate": 0.5,
|
||||
"recovery_rate": 0.5,
|
||||
"thresholds": {"level_1": 100, "level_2": 500, "level_3": 1000},
|
||||
"multipliers": {"L0": 1.0, "L1": 0.5, "L2": 0.1, "L3": 0.0}
|
||||
},
|
||||
@@ -98,12 +110,15 @@ class GamificationService:
|
||||
# 7. NAPLÓZÁS
|
||||
db.add(PointsLedger(user_id=user_id, points=final_xp, reason=reason))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(stats)
|
||||
# Only commit if caller wants us to manage the transaction
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(stats)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
if commit:
|
||||
await db.rollback()
|
||||
logger.error(f"Gamification Error for user {user_id}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
@@ -66,8 +66,10 @@ class MaintenanceService:
|
||||
alerts_generated = 0
|
||||
for asset, telemetry in result.all():
|
||||
# A Robot 3 által feltöltött gyári adat (pl. 15.000 km)
|
||||
interval = asset.factory_data.get("oil_change_km") if asset.factory_data else None
|
||||
last_service_km = asset.factory_data.get("last_service_km", 0)
|
||||
# A factory_data az AssetCatalog (vehicle_catalog) táblában van
|
||||
catalog_data = asset.catalog.factory_data if asset.catalog else {}
|
||||
interval = catalog_data.get("oil_change_km") if catalog_data else None
|
||||
last_service_km = catalog_data.get("last_service_km", 0)
|
||||
|
||||
if interval and telemetry.current_mileage:
|
||||
next_service_due = last_service_km + interval
|
||||
|
||||
@@ -1,213 +1,15 @@
|
||||
"""
|
||||
Smart Odometer Service - Adminisztrátor által paraméterezhető kilométeróra becslés.
|
||||
[DEPRECATED] Smart Odometer Service - VehicleOdometerState model removed.
|
||||
|
||||
A szolgáltatás a járművek kilométeróra állását becsüli a költségbejegyzések alapján,
|
||||
figyelembe véve a rendszerparamétereket (ODOMETER_MIN_DAYS_FOR_AVG, ODOMETER_CONFIDENCE_THRESHOLD).
|
||||
Ha az admin beállított manuális átlagot (manual_override_avg), akkor azt használja.
|
||||
This service is deprecated. The VehicleOdometerState model has been removed
|
||||
from the schema. Odometer tracking is now handled directly via the
|
||||
OdometerReading table (audit trail) and Asset.current_mileage field.
|
||||
|
||||
The predictive odometer state estimation logic has been removed to keep
|
||||
the entity model clean. Use OdometerReading records directly for
|
||||
mileage audit trail purposes.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.vehicle import VehicleOdometerState, VehicleCost
|
||||
from app.models.system import SystemParameter
|
||||
from app.models import VehicleModelDefinition
|
||||
|
||||
|
||||
class OdometerService:
|
||||
"""Kilométeróra becslési szolgáltatás adminisztrációs kontrollal."""
|
||||
|
||||
@staticmethod
|
||||
async def get_system_param(db: AsyncSession, key: str, default_value):
|
||||
"""Rendszerparaméter lekérése a system.system_parameters táblából."""
|
||||
stmt = select(SystemParameter).where(
|
||||
SystemParameter.key == key,
|
||||
SystemParameter.scope_level == 'global',
|
||||
SystemParameter.is_active == True
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
param = result.scalars().first()
|
||||
if param and 'value' in param.value:
|
||||
return param.value['value']
|
||||
return default_value
|
||||
|
||||
@staticmethod
|
||||
async def update_vehicle_stats(db: AsyncSession, vehicle_id: int) -> Optional[VehicleOdometerState]:
|
||||
"""
|
||||
Frissíti a jármű kilométeróra statisztikáit.
|
||||
|
||||
Algoritmus:
|
||||
1. Ha van manual_override_avg, használja azt.
|
||||
2. Különben számol átlagot a vehicle.costs bejegyzésekből.
|
||||
3. Figyelembe veszi az ODOMETER_MIN_DAYS_FOR_AVG paramétert.
|
||||
4. Kiszámolja a confidence_score-t a minták száma alapján.
|
||||
5. Frissíti vagy létrehozza a VehicleOdometerState rekordot.
|
||||
"""
|
||||
# Rendszerparaméterek lekérése
|
||||
min_days = await OdometerService.get_system_param(db, 'ODOMETER_MIN_DAYS_FOR_AVG', 7)
|
||||
confidence_threshold = await OdometerService.get_system_param(db, 'ODOMETER_CONFIDENCE_THRESHOLD', 0.5)
|
||||
|
||||
# Meglévő állapot lekérése
|
||||
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
|
||||
result = await db.execute(stmt)
|
||||
odometer_state = result.scalars().first()
|
||||
|
||||
# Költségbejegyzések lekérése dátum és odometer szerint rendezve
|
||||
cost_stmt = select(VehicleCost).where(
|
||||
VehicleCost.vehicle_id == vehicle_id,
|
||||
VehicleCost.odometer.isnot(None)
|
||||
).order_by(VehicleCost.date.asc())
|
||||
|
||||
cost_result = await db.execute(cost_stmt)
|
||||
costs = cost_result.scalars().all()
|
||||
|
||||
if not costs:
|
||||
# Nincs adat, alapértelmezett értékek
|
||||
if odometer_state:
|
||||
odometer_state.daily_avg_distance = 0
|
||||
odometer_state.confidence_score = 0
|
||||
odometer_state.estimated_current_odometer = odometer_state.last_recorded_odometer
|
||||
else:
|
||||
# Jármű alapadatok lekérése
|
||||
vehicle_stmt = select(VehicleModelDefinition).where(VehicleModelDefinition.id == vehicle_id)
|
||||
vehicle_result = await db.execute(vehicle_stmt)
|
||||
vehicle = vehicle_result.scalars().first()
|
||||
|
||||
if not vehicle:
|
||||
return None
|
||||
|
||||
odometer_state = VehicleOdometerState(
|
||||
vehicle_id=vehicle_id,
|
||||
last_recorded_odometer=0,
|
||||
last_recorded_date=datetime.now(),
|
||||
daily_avg_distance=0,
|
||||
estimated_current_odometer=0,
|
||||
confidence_score=0,
|
||||
manual_override_avg=None
|
||||
)
|
||||
db.add(odometer_state)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(odometer_state)
|
||||
return odometer_state
|
||||
|
||||
# Utolsó rögzített adatok
|
||||
last_cost = costs[-1]
|
||||
last_recorded_odometer = last_cost.odometer
|
||||
last_recorded_date = last_cost.date
|
||||
|
||||
# Manuális átlag ellenőrzése
|
||||
if odometer_state and odometer_state.manual_override_avg is not None:
|
||||
daily_avg = float(odometer_state.manual_override_avg)
|
||||
confidence = 1.0 # Manuális beállítás esetén teljes bizalom
|
||||
else:
|
||||
# Átlag számítása a költségbejegyzésekből
|
||||
valid_pairs = []
|
||||
for i in range(1, len(costs)):
|
||||
prev = costs[i-1]
|
||||
curr = costs[i]
|
||||
|
||||
days_diff = (curr.date - prev.date).days
|
||||
km_diff = curr.odometer - prev.odometer
|
||||
|
||||
if days_diff >= min_days and km_diff > 0:
|
||||
daily_avg = km_diff / days_diff
|
||||
valid_pairs.append((daily_avg, days_diff))
|
||||
|
||||
if valid_pairs:
|
||||
# Súlyozott átlag (hosszabb időszakok nagyobb súllyal)
|
||||
total_weighted = sum(avg * weight for avg, weight in valid_pairs)
|
||||
total_days = sum(weight for _, weight in valid_pairs)
|
||||
daily_avg = total_weighted / total_days if total_days > 0 else 0
|
||||
|
||||
# Confidence score: érvényes párok száma / összes lehetséges párok
|
||||
confidence = min(len(valid_pairs) / max(len(costs) - 1, 1), 1.0)
|
||||
else:
|
||||
daily_avg = 0
|
||||
confidence = 0
|
||||
|
||||
# Becsült jelenlegi kilométer
|
||||
days_since_last = (datetime.now(last_recorded_date.tzinfo) - last_recorded_date).days
|
||||
estimated_odometer = last_recorded_odometer + (daily_avg * max(days_since_last, 0))
|
||||
|
||||
# Állapot frissítése vagy létrehozása
|
||||
if odometer_state:
|
||||
odometer_state.last_recorded_odometer = last_recorded_odometer
|
||||
odometer_state.last_recorded_date = last_recorded_date
|
||||
odometer_state.daily_avg_distance = daily_avg
|
||||
odometer_state.estimated_current_odometer = estimated_odometer
|
||||
odometer_state.confidence_score = confidence
|
||||
else:
|
||||
odometer_state = VehicleOdometerState(
|
||||
vehicle_id=vehicle_id,
|
||||
last_recorded_odometer=last_recorded_odometer,
|
||||
last_recorded_date=last_recorded_date,
|
||||
daily_avg_distance=daily_avg,
|
||||
estimated_current_odometer=estimated_odometer,
|
||||
confidence_score=confidence,
|
||||
manual_override_avg=None
|
||||
)
|
||||
db.add(odometer_state)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(odometer_state)
|
||||
return odometer_state
|
||||
|
||||
@staticmethod
|
||||
async def get_estimated_odometer(db: AsyncSession, vehicle_id: int) -> Tuple[Optional[float], float]:
|
||||
"""
|
||||
Visszaadja a jármű becsült jelenlegi kilométeróra állását és a bizalom pontszámot.
|
||||
|
||||
Returns:
|
||||
Tuple[estimated_odometer, confidence_score]
|
||||
"""
|
||||
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
|
||||
result = await db.execute(stmt)
|
||||
odometer_state = result.scalars().first()
|
||||
|
||||
if not odometer_state:
|
||||
# Ha nincs állapot, frissítsük
|
||||
odometer_state = await OdometerService.update_vehicle_stats(db, vehicle_id)
|
||||
if not odometer_state:
|
||||
return None, 0.0
|
||||
|
||||
return odometer_state.estimated_current_odometer, odometer_state.confidence_score
|
||||
|
||||
@staticmethod
|
||||
async def set_manual_override(db: AsyncSession, vehicle_id: int, daily_avg: Optional[float]) -> Optional[VehicleOdometerState]:
|
||||
"""
|
||||
Adminisztrátori manuális átlag beállítása.
|
||||
|
||||
Args:
|
||||
daily_avg: Napi átlagos kilométer (km/nap). Ha None, törli a manuális beállítást.
|
||||
"""
|
||||
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
|
||||
result = await db.execute(stmt)
|
||||
odometer_state = result.scalars().first()
|
||||
|
||||
if not odometer_state:
|
||||
# Ha nincs állapot, hozzuk létre
|
||||
odometer_state = VehicleOdometerState(
|
||||
vehicle_id=vehicle_id,
|
||||
last_recorded_odometer=0,
|
||||
last_recorded_date=datetime.now(),
|
||||
daily_avg_distance=0,
|
||||
estimated_current_odometer=0,
|
||||
confidence_score=0,
|
||||
manual_override_avg=daily_avg
|
||||
)
|
||||
db.add(odometer_state)
|
||||
else:
|
||||
odometer_state.manual_override_avg = daily_avg
|
||||
# Frissítsük a becslést a manuális átlaggal
|
||||
if daily_avg is not None:
|
||||
days_since_last = (datetime.now(odometer_state.last_recorded_date.tzinfo) - odometer_state.last_recorded_date).days
|
||||
odometer_state.estimated_current_odometer = odometer_state.last_recorded_odometer + (daily_avg * max(days_since_last, 0))
|
||||
odometer_state.confidence_score = 1.0
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(odometer_state)
|
||||
return odometer_state
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("OdometerService is deprecated. VehicleOdometerState model has been removed.")
|
||||
|
||||
162
backend/app/services/subscription_service.py
Normal file
162
backend/app/services/subscription_service.py
Normal file
@@ -0,0 +1,162 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/subscription_service.py
|
||||
"""
|
||||
Subscription & Entitlement Service — Premium költségkategória láthatóság.
|
||||
|
||||
A szolgáltatás a felhasználó előfizetési szintje (tier) alapján szabályozza,
|
||||
hogy mely költségkategóriák (CostCategory) érhetők el. A tier-ek hierarchikusak:
|
||||
free < premium < enterprise.
|
||||
|
||||
Hierarchia:
|
||||
- free: alap kategóriák (üzemanyag, szerviz, gumik, biztosítás, adók)
|
||||
- premium: free + útdíj/parkolás, ápolás, egyéb
|
||||
- enterprise: premium + minden (nincs korlátozás)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.vehicle.vehicle import CostCategory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Subscription Feature Matrix ──────────────────────────────────────────────
|
||||
# Minden funkció/kategória minimális tier szintje.
|
||||
# A tier-ek: free < premium < enterprise
|
||||
TIER_HIERARCHY = {
|
||||
"free": 0,
|
||||
"premium": 1,
|
||||
"enterprise": 2,
|
||||
}
|
||||
|
||||
SUBSCRIPTION_FEATURES: dict[str, str] = {
|
||||
# Feature -> minimum required tier
|
||||
"cost_category:fuel": "free",
|
||||
"cost_category:service": "free",
|
||||
"cost_category:tires": "free",
|
||||
"cost_category:insurance": "free",
|
||||
"cost_category:taxes": "free",
|
||||
"cost_category:toll_parking": "premium",
|
||||
"cost_category:cleaning": "premium",
|
||||
"cost_category:other": "premium",
|
||||
"cost_category:all": "enterprise",
|
||||
# Feature flags
|
||||
"export_csv": "free",
|
||||
"analytics_tco": "premium",
|
||||
"analytics_detailed": "enterprise",
|
||||
"api_access": "free",
|
||||
"multi_vehicle": "free",
|
||||
"unlimited_vehicles": "premium",
|
||||
}
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
"""
|
||||
Entitlement Mátrix — előfizetési szintek kezelése.
|
||||
|
||||
Metódusok:
|
||||
get_user_tier(db, user_id) -> str
|
||||
can_access_feature(tier, feature_key) -> bool
|
||||
get_visible_categories(db, tier) -> list[CostCategory]
|
||||
require_tier(tier, required_feature_or_min_tier) -> bool
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _tier_to_int(tier: str) -> int:
|
||||
"""Tier string konvertálása numerikus értékre a hierarchiában."""
|
||||
return TIER_HIERARCHY.get(tier.lower(), 0)
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tier(db: AsyncSession, user_id: int) -> str:
|
||||
"""
|
||||
Visszaadja a felhasználó aktuális előfizetési szintjét.
|
||||
|
||||
Jelenlegi implementáció: minden user 'free' tier-t kap.
|
||||
A jövőben ez a subscription/org táblákból lesz lekérdezve.
|
||||
"""
|
||||
# TODO: Éles implementáció — subscription tábla lekérdezése
|
||||
# TODO: Szervezeti tier felülírás (pl. enterprise org)
|
||||
return "free"
|
||||
|
||||
@staticmethod
|
||||
def can_access_feature(user_tier: str, feature_key: str) -> bool:
|
||||
"""
|
||||
Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz.
|
||||
|
||||
Args:
|
||||
user_tier: A felhasználó tier szintje (free/premium/enterprise)
|
||||
feature_key: A funkció kulcsa (pl. 'cost_category:fuel', 'analytics_tco')
|
||||
|
||||
Returns:
|
||||
bool: True ha hozzáfér, False ha nem
|
||||
"""
|
||||
required_tier = SUBSCRIPTION_FEATURES.get(feature_key)
|
||||
if required_tier is None:
|
||||
# Ismeretlen feature — alapértelmezetten tiltva
|
||||
logger.warning(f"Unknown feature key: {feature_key}")
|
||||
return False
|
||||
|
||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
||||
required_level = SubscriptionService._tier_to_int(required_tier)
|
||||
return user_level >= required_level
|
||||
|
||||
@staticmethod
|
||||
async def get_visible_categories(
|
||||
db: AsyncSession,
|
||||
user_tier: str
|
||||
) -> List[CostCategory]:
|
||||
"""
|
||||
Visszaadja azokat a CostCategory-ket, amelyek a user tier-jének megfelelnek.
|
||||
|
||||
Szűrési logika:
|
||||
- A kategória min_tier mezője alapján szűrünk.
|
||||
- Ha a kategória min_tier értéke kisebb vagy egyenlő, mint a user tier,
|
||||
akkor látható.
|
||||
- Ha a min_tier nincs beállítva (None), akkor 'free'-ként kezeljük.
|
||||
|
||||
Args:
|
||||
db: Adatbázis session
|
||||
user_tier: A felhasználó tier szintje
|
||||
|
||||
Returns:
|
||||
List[CostCategory]: A látható kategóriák listája
|
||||
"""
|
||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
||||
|
||||
# Lekérjük az összes kategóriát
|
||||
stmt = select(CostCategory).order_by(CostCategory.id)
|
||||
result = await db.execute(stmt)
|
||||
all_categories = result.scalars().all()
|
||||
|
||||
# Szűrés a min_tier alapján
|
||||
visible = []
|
||||
for cat in all_categories:
|
||||
cat_tier = getattr(cat, "min_tier", "free") or "free"
|
||||
cat_level = SubscriptionService._tier_to_int(cat_tier)
|
||||
if user_level >= cat_level:
|
||||
visible.append(cat)
|
||||
|
||||
return visible
|
||||
|
||||
@staticmethod
|
||||
def require_tier(user_tier: str, required_feature_or_min_tier: str) -> bool:
|
||||
"""
|
||||
Egyszerű tier ellenőrzés — dependency check-hez.
|
||||
|
||||
Args:
|
||||
user_tier: A felhasználó tier szintje
|
||||
required_feature_or_min_tier: Vagy egy feature kulcs, vagy egy tier név
|
||||
|
||||
Returns:
|
||||
bool: True ha megfelel, False ha nem
|
||||
"""
|
||||
# Először feature kulcsként próbáljuk
|
||||
if required_feature_or_min_tier in SUBSCRIPTION_FEATURES:
|
||||
return SubscriptionService.can_access_feature(
|
||||
user_tier, required_feature_or_min_tier
|
||||
)
|
||||
|
||||
# Ha nem feature kulcs, akkor tier névként kezeljük
|
||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
||||
required_level = SubscriptionService._tier_to_int(required_feature_or_min_tier)
|
||||
return user_level >= required_level
|
||||
Reference in New Issue
Block a user