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

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