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."}
|
||||
|
||||
Reference in New Issue
Block a user