admin frontend elkezdése, járművek tisztázása, frontend fejleszts
This commit is contained in:
@@ -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."
|
||||
}
|
||||
Reference in New Issue
Block a user