admin frontend elkezdése, járművek tisztázása, frontend fejleszts

This commit is contained in:
Roo
2026-06-15 18:52:38 +00:00
parent ef8df9608c
commit 213ba3b0f1
80 changed files with 11615 additions and 2304 deletions

View File

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

View File

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

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

View 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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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