1136 lines
47 KiB
Python
1136 lines
47 KiB
Python
import csv
|
||
import io
|
||
import json
|
||
from datetime import UTC, date, datetime, timedelta
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from fastapi.encoders import jsonable_encoder
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
from sqlalchemy import Select, func, or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api.deps import get_current_telegram_user, log_audit, require_platform_role
|
||
from app.db.session import get_session
|
||
from app.models.car import (
|
||
AdminExportJob,
|
||
AdminNotification,
|
||
AuditLog,
|
||
Car,
|
||
CarServiceLink,
|
||
ServiceAppointment,
|
||
ServiceCenter,
|
||
ServiceCenterReview,
|
||
ServiceCenterVerification,
|
||
ServiceEmployee,
|
||
ServiceNotification,
|
||
ServiceProductItem,
|
||
ServiceVisit,
|
||
ServiceWorkItem,
|
||
)
|
||
from app.models.expense import ExpenseEntry, FuelEntry, ServiceEntry
|
||
from app.models.user import User
|
||
from app.schemas.service_center import AdminModerationDecision, ServiceCenterRead, ServiceVisitRead
|
||
from app.services.admin_notifications import (
|
||
create_admin_notification,
|
||
dismiss_admin_notification,
|
||
mark_admin_notification_read,
|
||
)
|
||
from app.services.notifications import notify_user
|
||
|
||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||
|
||
ADMIN_ROLES = {"admin", "super_admin", "moderator", "support", "analyst"}
|
||
FULL_ADMIN_ROLES = {"admin", "super_admin"}
|
||
MODERATION_ROLES = {"admin", "super_admin", "moderator"}
|
||
DATA_EXPORT_ROLES = {"admin", "super_admin", "analyst"}
|
||
|
||
|
||
class AdminDataQuery(BaseModel):
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
source: str
|
||
date_from: date | None = None
|
||
date_to: date | None = None
|
||
status: str | None = None
|
||
user_id: int | None = None
|
||
telegram_id: int | None = None
|
||
vehicle_id: int | None = None
|
||
sto_id: int | None = None
|
||
city: str | None = None
|
||
role: str | None = None
|
||
category: str | None = None
|
||
amount_min: Decimal | None = None
|
||
amount_max: Decimal | None = None
|
||
has_errors: bool | None = None
|
||
is_pending: bool | None = None
|
||
is_completed: bool | None = None
|
||
search: str | None = None
|
||
sort: str = "created_at_desc"
|
||
limit: int = Field(default=50, ge=1, le=500)
|
||
offset: int = Field(default=0, ge=0)
|
||
include_sensitive: bool = False
|
||
reason: str | None = None
|
||
|
||
|
||
class AdminExportRequest(AdminDataQuery):
|
||
export_format: str = "json"
|
||
|
||
|
||
class AdminUserNote(BaseModel):
|
||
note: str
|
||
|
||
|
||
DATA_SOURCES: dict[str, dict[str, Any]] = {
|
||
"users": {
|
||
"model": User,
|
||
"roles": ADMIN_ROLES,
|
||
"search": ["username", "first_name", "last_name"],
|
||
"filters": {"user_id": "id", "telegram_id": "telegram_id", "role": "platform_role"},
|
||
"sensitive": {"telegram_id"},
|
||
"columns": ["id", "telegram_id", "username", "first_name", "last_name", "platform_role", "created_at", "updated_at"],
|
||
},
|
||
"vehicles": {
|
||
"model": Car,
|
||
"roles": ADMIN_ROLES,
|
||
"search": ["name", "make", "model", "vin", "plate_number", "license_plate_display"],
|
||
"filters": {"user_id": "owner_id", "vehicle_id": "id"},
|
||
"sensitive": {"vin", "plate_number", "license_plate_display", "vin_normalized", "license_plate_normalized"},
|
||
"columns": ["id", "owner_id", "name", "make", "model", "year", "vin", "plate_number", "license_plate_display", "current_odometer", "created_at"],
|
||
},
|
||
"fuel_entries": {
|
||
"model": FuelEntry,
|
||
"roles": FULL_ADMIN_ROLES | {"analyst"},
|
||
"filters": {"vehicle_id": "car_id"},
|
||
"amount": "total_cost",
|
||
"date": "entry_date",
|
||
"columns": ["id", "car_id", "entry_date", "odometer", "liters", "total_cost", "created_at"],
|
||
},
|
||
"service_entries": {
|
||
"model": ServiceEntry,
|
||
"roles": FULL_ADMIN_ROLES | {"analyst", "support"},
|
||
"search": ["title", "vendor", "category"],
|
||
"filters": {"vehicle_id": "car_id", "category": "category"},
|
||
"amount": "total_cost",
|
||
"date": "entry_date",
|
||
"columns": ["id", "car_id", "entry_date", "service_type", "title", "total_cost", "created_at"],
|
||
},
|
||
"expense_entries": {
|
||
"model": ExpenseEntry,
|
||
"roles": FULL_ADMIN_ROLES | {"analyst"},
|
||
"search": ["title", "vendor"],
|
||
"filters": {"vehicle_id": "car_id", "category": "category"},
|
||
"amount": "total_cost",
|
||
"date": "entry_date",
|
||
"columns": ["id", "car_id", "entry_date", "category", "title", "total_cost", "currency", "created_at"],
|
||
},
|
||
"sto_profiles": {
|
||
"model": ServiceCenter,
|
||
"roles": ADMIN_ROLES,
|
||
"search": ["name", "display_name", "legal_name", "city"],
|
||
"filters": {"sto_id": "id", "city": "city", "status": "verification_status", "user_id": "owner_user_id"},
|
||
"sensitive": {"phone", "contact_phone", "business_registration_number"},
|
||
"columns": ["id", "display_name", "legal_name", "city", "phone", "verification_status", "owner_user_id", "created_at", "verified_at"],
|
||
},
|
||
"sto_applications": {
|
||
"model": ServiceCenterVerification,
|
||
"roles": MODERATION_ROLES,
|
||
"filters": {"sto_id": "service_center_id", "status": "status", "user_id": "reviewed_by"},
|
||
"columns": ["id", "service_center_id", "status", "reviewed_by", "reviewed_at", "created_at", "comment"],
|
||
},
|
||
"sto_employees": {
|
||
"model": ServiceEmployee,
|
||
"roles": MODERATION_ROLES | {"support"},
|
||
"filters": {"sto_id": "service_center_id", "user_id": "user_id", "role": "role", "status": "status"},
|
||
"columns": ["id", "service_center_id", "user_id", "role", "status", "created_at"],
|
||
},
|
||
"vehicle_sto_links": {
|
||
"model": CarServiceLink,
|
||
"roles": MODERATION_ROLES | {"support"},
|
||
"filters": {"vehicle_id": "car_id", "sto_id": "service_center_id", "status": "status"},
|
||
"columns": ["id", "car_id", "service_center_id", "access_level", "status", "created_at"],
|
||
},
|
||
"appointments": {
|
||
"model": ServiceAppointment,
|
||
"roles": ADMIN_ROLES,
|
||
"filters": {"vehicle_id": "vehicle_id", "sto_id": "service_center_id", "user_id": "owner_user_id", "status": "status"},
|
||
"columns": ["id", "service_center_id", "vehicle_id", "owner_user_id", "service_type", "service_name", "status", "requested_start_at", "created_at"],
|
||
},
|
||
"work_orders": {
|
||
"model": ServiceVisit,
|
||
"roles": ADMIN_ROLES,
|
||
"filters": {"vehicle_id": "vehicle_id", "sto_id": "service_center_id", "user_id": "owner_user_id", "status": "status"},
|
||
"amount": "final_total",
|
||
"columns": ["id", "service_center_id", "vehicle_id", "owner_user_id", "status", "final_total", "currency", "created_at", "completed_at"],
|
||
},
|
||
"work_order_items": {
|
||
"model": ServiceWorkItem,
|
||
"roles": FULL_ADMIN_ROLES | {"support"},
|
||
"filters": {"category": "category"},
|
||
"amount": "total",
|
||
"columns": ["id", "service_visit_id", "work_type", "title", "category", "quantity", "total", "created_at"],
|
||
},
|
||
"work_order_products": {
|
||
"model": ServiceProductItem,
|
||
"roles": FULL_ADMIN_ROLES | {"support"},
|
||
"filters": {"category": "category"},
|
||
"amount": "total",
|
||
"columns": ["id", "service_visit_id", "title", "category", "product_type", "quantity", "total", "created_at"],
|
||
},
|
||
"reviews": {
|
||
"model": ServiceCenterReview,
|
||
"roles": MODERATION_ROLES | {"support", "analyst"},
|
||
"filters": {"sto_id": "service_center_id", "user_id": "user_id", "status": "status"},
|
||
"columns": ["id", "service_center_id", "user_id", "rating", "status", "created_at"],
|
||
},
|
||
"notifications": {
|
||
"model": ServiceNotification,
|
||
"roles": FULL_ADMIN_ROLES | {"support"},
|
||
"filters": {"user_id": "recipient_user_id", "status": "status", "sto_id": "service_center_id"},
|
||
"columns": ["id", "recipient_user_id", "notification_type", "title", "status", "created_at", "sent_at"],
|
||
},
|
||
"admin_notifications": {
|
||
"model": AdminNotification,
|
||
"roles": ADMIN_ROLES,
|
||
"filters": {"status": "status"},
|
||
"columns": ["id", "event_type", "severity", "title", "entity_type", "entity_id", "status", "created_at"],
|
||
},
|
||
"audit_logs": {
|
||
"model": AuditLog,
|
||
"roles": FULL_ADMIN_ROLES | {"moderator", "support"},
|
||
"search": ["action", "target_type", "target_id"],
|
||
"filters": {"user_id": "actor_user_id", "role": "actor_role"},
|
||
"columns": ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "ip", "created_at"],
|
||
},
|
||
"ocr_results": {"model": None, "roles": ADMIN_ROLES, "columns": []},
|
||
"imports_exports": {
|
||
"model": AdminExportJob,
|
||
"roles": DATA_EXPORT_ROLES,
|
||
"filters": {"user_id": "requested_by_user_id", "status": "status"},
|
||
"columns": ["id", "requested_by_user_id", "source", "export_format", "status", "row_count", "created_at"],
|
||
},
|
||
}
|
||
|
||
|
||
def require_admin_or_verifier(user: User) -> None:
|
||
require_platform_role(user, MODERATION_ROLES | {"verifier"})
|
||
|
||
|
||
def require_admin_access(user: User, allowed: set[str] | None = None) -> None:
|
||
require_platform_role(user, allowed or ADMIN_ROLES)
|
||
|
||
|
||
def mask_text(value: Any, visible: int = 4) -> Any:
|
||
if value is None:
|
||
return None
|
||
text = str(value)
|
||
if len(text) <= visible:
|
||
return "*" * len(text)
|
||
return f"{text[:2]}{'*' * max(len(text) - visible, 3)}{text[-2:]}"
|
||
|
||
|
||
def can_view_sensitive(user: User, query: AdminDataQuery) -> bool:
|
||
if not query.include_sensitive:
|
||
return False
|
||
if user.platform_role not in FULL_ADMIN_ROLES:
|
||
raise HTTPException(status_code=403, detail="Sensitive data is restricted")
|
||
if not query.reason or len(query.reason.strip()) < 5:
|
||
raise HTTPException(status_code=400, detail="reason is required for sensitive data")
|
||
return True
|
||
|
||
|
||
def public_columns(source: str) -> list[str]:
|
||
return list(DATA_SOURCES[source].get("columns") or [])
|
||
|
||
|
||
def serialize_row(row: Any, source: str, *, include_sensitive: bool, role: str) -> dict[str, Any]:
|
||
config = DATA_SOURCES[source]
|
||
sensitive = set(config.get("sensitive") or set())
|
||
columns = public_columns(source)
|
||
payload: dict[str, Any] = {}
|
||
for column in columns:
|
||
value = getattr(row, column, None)
|
||
if role == "analyst" and column in {"telegram_id", "username", "first_name", "last_name", "owner_id", "user_id"}:
|
||
value = mask_text(value)
|
||
elif column in sensitive and not include_sensitive:
|
||
value = mask_text(value)
|
||
payload[column] = jsonable_encoder(value)
|
||
return payload
|
||
|
||
|
||
def source_config(source: str, user: User) -> dict[str, Any]:
|
||
config = DATA_SOURCES.get(source)
|
||
if config is None or config.get("model") is None:
|
||
raise HTTPException(status_code=400, detail="Unsupported data source")
|
||
require_admin_access(user, set(config["roles"]))
|
||
return config
|
||
|
||
|
||
def apply_data_filters(stmt: Select, query: AdminDataQuery, config: dict[str, Any]) -> Select:
|
||
model = config["model"]
|
||
date_column = config.get("date", "created_at")
|
||
if hasattr(model, date_column):
|
||
column = getattr(model, date_column)
|
||
if query.date_from:
|
||
stmt = stmt.where(column >= query.date_from)
|
||
if query.date_to:
|
||
stmt = stmt.where(column <= query.date_to)
|
||
for query_field, model_field in (config.get("filters") or {}).items():
|
||
value = getattr(query, query_field)
|
||
if value is not None and value != "" and hasattr(model, model_field):
|
||
stmt = stmt.where(getattr(model, model_field) == value)
|
||
if query.is_pending is True and hasattr(model, "status"):
|
||
stmt = stmt.where(model.status.in_(["pending", "requested", "unread"]))
|
||
if query.is_completed is True and hasattr(model, "status"):
|
||
stmt = stmt.where(model.status.in_(["completed", "closed", "confirmed"]))
|
||
amount_column = config.get("amount")
|
||
if amount_column and hasattr(model, amount_column):
|
||
column = getattr(model, amount_column)
|
||
if query.amount_min is not None:
|
||
stmt = stmt.where(column >= query.amount_min)
|
||
if query.amount_max is not None:
|
||
stmt = stmt.where(column <= query.amount_max)
|
||
if query.has_errors is not None:
|
||
if hasattr(model, "last_error"):
|
||
stmt = stmt.where(model.last_error.is_not(None) if query.has_errors else model.last_error.is_(None))
|
||
elif hasattr(model, "telegram_error"):
|
||
stmt = stmt.where(model.telegram_error.is_not(None) if query.has_errors else model.telegram_error.is_(None))
|
||
if query.search:
|
||
search_fields = [field for field in config.get("search", []) if hasattr(model, field)]
|
||
if search_fields:
|
||
pattern = f"%{query.search}%"
|
||
stmt = stmt.where(or_(*(getattr(model, field).ilike(pattern) for field in search_fields)))
|
||
return stmt
|
||
|
||
|
||
def apply_sort(stmt: Select, query: AdminDataQuery, config: dict[str, Any]) -> Select:
|
||
model = config["model"]
|
||
sort_map = {
|
||
"created_at_desc": ("created_at", True),
|
||
"created_at_asc": ("created_at", False),
|
||
"updated_at_desc": ("updated_at", True),
|
||
"amount_desc": (config.get("amount") or "created_at", True),
|
||
"status": ("status", False),
|
||
"city": ("city", False),
|
||
}
|
||
field, desc = sort_map.get(query.sort, ("created_at", True))
|
||
if not hasattr(model, field):
|
||
field = "id"
|
||
column = getattr(model, field)
|
||
return stmt.order_by(column.desc() if desc else column.asc())
|
||
|
||
|
||
async def run_data_query(
|
||
session: AsyncSession, current_user: User, query: AdminDataQuery
|
||
) -> dict[str, Any]:
|
||
config = source_config(query.source, current_user)
|
||
include_sensitive = can_view_sensitive(current_user, query)
|
||
stmt = apply_sort(apply_data_filters(select(config["model"]), query, config), query, config)
|
||
result = await session.execute(stmt.limit(query.limit).offset(query.offset))
|
||
rows = [
|
||
serialize_row(item, query.source, include_sensitive=include_sensitive, role=current_user.platform_role)
|
||
for item in result.scalars()
|
||
]
|
||
await log_audit(
|
||
session,
|
||
actor=current_user,
|
||
action="admin.data.query",
|
||
target_type=query.source,
|
||
metadata={
|
||
"filters": query.model_dump(mode="json", exclude={"reason"}),
|
||
"include_sensitive": include_sensitive,
|
||
"reason": query.reason if include_sensitive else None,
|
||
"rows": len(rows),
|
||
},
|
||
)
|
||
return {"source": query.source, "limit": query.limit, "offset": query.offset, "rows": rows}
|
||
|
||
|
||
def csv_from_rows(rows: list[dict[str, Any]]) -> str:
|
||
output = io.StringIO()
|
||
if not rows:
|
||
return ""
|
||
writer = csv.DictWriter(output, fieldnames=list(rows[0].keys()))
|
||
writer.writeheader()
|
||
writer.writerows(rows)
|
||
return output.getvalue()
|
||
|
||
|
||
@router.get("/dashboard")
|
||
async def admin_dashboard(
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user)
|
||
today = datetime.now(UTC).date()
|
||
week_ago = today - timedelta(days=6)
|
||
|
||
async def count(stmt: Select) -> int:
|
||
return int((await session.execute(stmt)).scalar_one() or 0)
|
||
|
||
users_total = await count(select(func.count(User.id)))
|
||
users_today = await count(select(func.count(User.id)).where(func.date(User.created_at) == today))
|
||
users_7d = await count(select(func.count(User.id)).where(func.date(User.created_at) >= week_ago))
|
||
vehicles_total = await count(select(func.count(Car.id)))
|
||
pending_sto = await count(select(func.count(ServiceCenter.id)).where(ServiceCenter.verification_status == "pending"))
|
||
approved_sto = await count(select(func.count(ServiceCenter.id)).where(ServiceCenter.verification_status.in_(["approved", "verified"])))
|
||
suspended_sto = await count(select(func.count(ServiceCenter.id)).where(ServiceCenter.verification_status == "suspended"))
|
||
appointments_today = await count(
|
||
select(func.count(ServiceAppointment.id)).where(func.date(ServiceAppointment.requested_start_at) == today)
|
||
)
|
||
active_work_orders = await count(
|
||
select(func.count(ServiceVisit.id)).where(ServiceVisit.status.in_(["draft", "awaiting_approval", "approved", "in_progress"]))
|
||
)
|
||
completed_work_orders = await count(select(func.count(ServiceVisit.id)).where(ServiceVisit.status == "completed"))
|
||
system_errors = await count(select(func.count(AdminNotification.id)).where(AdminNotification.severity.in_(["error", "critical"])))
|
||
security_events = await count(select(func.count(AdminNotification.id)).where(AdminNotification.event_type.in_(["security_event", "rate_limit_exceeded", "upload_blocked"])))
|
||
latest_alerts = (
|
||
await session.execute(select(AdminNotification).order_by(AdminNotification.created_at.desc()).limit(8))
|
||
).scalars()
|
||
return {
|
||
"users_today": users_today,
|
||
"users_7d": users_7d,
|
||
"users_total": users_total,
|
||
"active_users": users_7d,
|
||
"vehicles_total": vehicles_total,
|
||
"new_sto_applications": pending_sto,
|
||
"pending_sto_applications": pending_sto,
|
||
"approved_sto": approved_sto,
|
||
"suspended_sto": suspended_sto,
|
||
"appointments_today": appointments_today,
|
||
"active_work_orders": active_work_orders,
|
||
"completed_work_orders": completed_work_orders,
|
||
"system_errors": system_errors,
|
||
"security_events": security_events,
|
||
"latest_alerts": [serialize_row(item, "admin_notifications", include_sensitive=False, role=current_user.platform_role) for item in latest_alerts],
|
||
}
|
||
|
||
|
||
@router.get("/notifications")
|
||
async def admin_notifications(
|
||
status: str | None = None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user)
|
||
limit = min(max(limit, 1), 200)
|
||
stmt = select(AdminNotification)
|
||
if status:
|
||
stmt = stmt.where(AdminNotification.status == status)
|
||
rows = (
|
||
await session.execute(stmt.order_by(AdminNotification.created_at.desc()).limit(limit).offset(max(offset, 0)))
|
||
).scalars()
|
||
return {"rows": [jsonable_encoder(item) for item in rows], "limit": limit, "offset": offset}
|
||
|
||
|
||
@router.post("/notifications/{notification_id}/read")
|
||
async def read_admin_notification(
|
||
notification_id: int,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user)
|
||
notification = await session.get(AdminNotification, notification_id)
|
||
if notification is None:
|
||
raise HTTPException(status_code=404, detail="Admin notification not found")
|
||
await mark_admin_notification_read(session, notification)
|
||
await log_audit(session, actor=current_user, action="admin_notification.read", target_type="admin_notification", target_id=notification_id)
|
||
await session.commit()
|
||
return jsonable_encoder(notification)
|
||
|
||
|
||
@router.post("/notifications/read-all")
|
||
async def read_all_admin_notifications(
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user)
|
||
rows = (await session.execute(select(AdminNotification).where(AdminNotification.status == "unread"))).scalars().all()
|
||
for notification in rows:
|
||
await mark_admin_notification_read(session, notification)
|
||
await log_audit(session, actor=current_user, action="admin_notification.read_all", target_type="admin_notification", metadata={"count": len(rows)})
|
||
await session.commit()
|
||
return {"updated": len(rows)}
|
||
|
||
|
||
@router.post("/notifications/{notification_id}/dismiss")
|
||
async def dismiss_notification(
|
||
notification_id: int,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user)
|
||
notification = await session.get(AdminNotification, notification_id)
|
||
if notification is None:
|
||
raise HTTPException(status_code=404, detail="Admin notification not found")
|
||
await dismiss_admin_notification(session, notification)
|
||
await log_audit(session, actor=current_user, action="admin_notification.dismiss", target_type="admin_notification", target_id=notification_id)
|
||
await session.commit()
|
||
return jsonable_encoder(notification)
|
||
|
||
|
||
@router.get("/data/sources")
|
||
async def admin_data_sources(current_user: User = Depends(get_current_telegram_user)) -> dict[str, Any]:
|
||
require_admin_access(current_user)
|
||
return {
|
||
"sources": [
|
||
{
|
||
"name": name,
|
||
"available": bool(config.get("model")),
|
||
"columns": config.get("columns") or [],
|
||
"sensitive": sorted(config.get("sensitive") or []),
|
||
"allowed": current_user.platform_role in set(config["roles"]),
|
||
}
|
||
for name, config in DATA_SOURCES.items()
|
||
],
|
||
"sorts": ["created_at_desc", "created_at_asc", "updated_at_desc", "amount_desc", "status", "city"],
|
||
"limits": [25, 50, 100, 500],
|
||
}
|
||
|
||
|
||
@router.post("/data/query")
|
||
async def admin_data_query(
|
||
payload: AdminDataQuery,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
data = await run_data_query(session, current_user, payload)
|
||
await session.commit()
|
||
return data
|
||
|
||
|
||
@router.post("/data/export")
|
||
async def admin_data_export(
|
||
payload: AdminExportRequest,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user, DATA_EXPORT_ROLES)
|
||
if payload.export_format not in {"json", "csv"}:
|
||
raise HTTPException(status_code=400, detail="Unsupported export format")
|
||
if payload.source in {"users", "vehicles", "sto_profiles"} and not payload.reason:
|
||
raise HTTPException(status_code=400, detail="Export reason is required")
|
||
data = await run_data_query(session, current_user, payload)
|
||
content = json.dumps(data["rows"], ensure_ascii=False, default=str, indent=2)
|
||
if payload.export_format == "csv":
|
||
content = csv_from_rows(data["rows"])
|
||
job = AdminExportJob(
|
||
requested_by_user_id=current_user.id,
|
||
source=payload.source,
|
||
export_format=payload.export_format,
|
||
status="ready",
|
||
reason=payload.reason,
|
||
filters_json=payload.model_dump(mode="json", exclude={"reason"}),
|
||
result_text=content,
|
||
row_count=len(data["rows"]),
|
||
expires_at=datetime.now(UTC) + timedelta(days=7),
|
||
)
|
||
session.add(job)
|
||
await log_audit(session, actor=current_user, action="admin.data.export", target_type=payload.source, metadata={"format": payload.export_format, "rows": len(data["rows"])})
|
||
await session.commit()
|
||
await session.refresh(job)
|
||
return {"id": job.id, "status": job.status, "row_count": job.row_count, "expires_at": job.expires_at}
|
||
|
||
|
||
@router.get("/exports")
|
||
async def admin_exports(
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user, DATA_EXPORT_ROLES)
|
||
rows = (await session.execute(select(AdminExportJob).order_by(AdminExportJob.created_at.desc()).limit(50))).scalars()
|
||
return {
|
||
"rows": [
|
||
{
|
||
"id": item.id,
|
||
"requested_by_user_id": item.requested_by_user_id,
|
||
"source": item.source,
|
||
"export_format": item.export_format,
|
||
"status": item.status,
|
||
"row_count": item.row_count,
|
||
"reason": item.reason,
|
||
"expires_at": item.expires_at,
|
||
"created_at": item.created_at,
|
||
}
|
||
for item in rows
|
||
]
|
||
}
|
||
|
||
|
||
@router.get("/exports/{export_id}")
|
||
async def admin_export_detail(
|
||
export_id: int,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user, DATA_EXPORT_ROLES)
|
||
job = await session.get(AdminExportJob, export_id)
|
||
if job is None:
|
||
raise HTTPException(status_code=404, detail="Export not found")
|
||
await log_audit(session, actor=current_user, action="admin.export.view", target_type="admin_export", target_id=export_id)
|
||
await session.commit()
|
||
return jsonable_encoder(job)
|
||
|
||
|
||
@router.get("/users")
|
||
async def admin_users(
|
||
search: str | None = None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
query = AdminDataQuery(source="users", search=search, limit=min(limit, 100), offset=offset)
|
||
data = await run_data_query(session, current_user, query)
|
||
await session.commit()
|
||
return data
|
||
|
||
|
||
@router.get("/users/{user_id}")
|
||
async def admin_user_detail(
|
||
user_id: int,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user, ADMIN_ROLES - {"analyst"})
|
||
user = await session.get(User, user_id)
|
||
if user is None:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
cars_count = int((await session.execute(select(func.count(Car.id)).where(Car.owner_id == user.id))).scalar_one() or 0)
|
||
fuel_count = int(
|
||
(
|
||
await session.execute(
|
||
select(func.count(FuelEntry.id)).join(Car, FuelEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||
)
|
||
).scalar_one()
|
||
or 0
|
||
)
|
||
appointments_count = int((await session.execute(select(func.count(ServiceAppointment.id)).where(ServiceAppointment.owner_user_id == user.id))).scalar_one() or 0)
|
||
await log_audit(session, actor=current_user, action="admin.user.view", target_type="user", target_id=user.id)
|
||
await session.commit()
|
||
return {
|
||
**serialize_row(user, "users", include_sensitive=current_user.platform_role in FULL_ADMIN_ROLES, role=current_user.platform_role),
|
||
"cars_count": cars_count,
|
||
"records_count": fuel_count,
|
||
"appointments_count": appointments_count,
|
||
}
|
||
|
||
|
||
@router.get("/users/{user_id}/activity")
|
||
async def admin_user_activity(
|
||
user_id: int,
|
||
limit: int = 50,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user, ADMIN_ROLES - {"analyst"})
|
||
rows = (
|
||
await session.execute(
|
||
select(AuditLog)
|
||
.where(AuditLog.actor_user_id == user_id)
|
||
.order_by(AuditLog.created_at.desc())
|
||
.limit(min(max(limit, 1), 100))
|
||
)
|
||
).scalars()
|
||
await log_audit(session, actor=current_user, action="admin.user.activity", target_type="user", target_id=user_id)
|
||
await session.commit()
|
||
return {"rows": [jsonable_encoder(item) for item in rows]}
|
||
|
||
|
||
@router.post("/users/{user_id}/note")
|
||
async def admin_user_note(
|
||
user_id: int,
|
||
payload: AdminUserNote,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, str]:
|
||
require_admin_access(current_user, {"admin", "super_admin", "support"})
|
||
await log_audit(session, actor=current_user, action="admin.user.note", target_type="user", target_id=user_id, metadata={"note": payload.note})
|
||
await session.commit()
|
||
return {"status": "saved"}
|
||
|
||
|
||
@router.post("/users/{user_id}/block")
|
||
async def admin_block_user(
|
||
user_id: int,
|
||
payload: AdminUserNote | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user, FULL_ADMIN_ROLES)
|
||
user = await session.get(User, user_id)
|
||
if user is None:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
user.platform_role = "blocked"
|
||
await log_audit(session, actor=current_user, action="admin.user.block", target_type="user", target_id=user_id, metadata={"reason": payload.note if payload else None})
|
||
await session.commit()
|
||
return {"id": user.id, "platform_role": user.platform_role}
|
||
|
||
|
||
@router.post("/users/{user_id}/unblock")
|
||
async def admin_unblock_user(
|
||
user_id: int,
|
||
payload: AdminUserNote | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user, FULL_ADMIN_ROLES)
|
||
user = await session.get(User, user_id)
|
||
if user is None:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
user.platform_role = "user"
|
||
await log_audit(session, actor=current_user, action="admin.user.unblock", target_type="user", target_id=user_id, metadata={"reason": payload.note if payload else None})
|
||
await session.commit()
|
||
return {"id": user.id, "platform_role": user.platform_role}
|
||
|
||
|
||
@router.get("/sto")
|
||
async def admin_sto(
|
||
status: str | None = None,
|
||
city: str | None = None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
query = AdminDataQuery(source="sto_profiles", status=status, city=city, limit=min(limit, 100), offset=offset)
|
||
data = await run_data_query(session, current_user, query)
|
||
await session.commit()
|
||
return data
|
||
|
||
|
||
@router.get("/sto/{service_center_id}")
|
||
async def admin_sto_detail(
|
||
service_center_id: int,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user, ADMIN_ROLES - {"analyst"})
|
||
center = await session.get(ServiceCenter, service_center_id)
|
||
if center is None:
|
||
raise HTTPException(status_code=404, detail="Service center not found")
|
||
employees_count = int((await session.execute(select(func.count(ServiceEmployee.id)).where(ServiceEmployee.service_center_id == center.id))).scalar_one() or 0)
|
||
appointments_count = int((await session.execute(select(func.count(ServiceAppointment.id)).where(ServiceAppointment.service_center_id == center.id))).scalar_one() or 0)
|
||
work_orders_count = int((await session.execute(select(func.count(ServiceVisit.id)).where(ServiceVisit.service_center_id == center.id))).scalar_one() or 0)
|
||
await log_audit(session, actor=current_user, action="admin.sto.view", target_type="service_center", target_id=center.id)
|
||
await session.commit()
|
||
return {
|
||
**serialize_row(center, "sto_profiles", include_sensitive=current_user.platform_role in FULL_ADMIN_ROLES, role=current_user.platform_role),
|
||
"employees_count": employees_count,
|
||
"appointments_count": appointments_count,
|
||
"work_orders_count": work_orders_count,
|
||
}
|
||
|
||
|
||
@router.get("/sto-applications")
|
||
async def admin_sto_applications(
|
||
status: str | None = None,
|
||
city: str | None = None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> dict[str, Any]:
|
||
require_admin_access(current_user, MODERATION_ROLES)
|
||
stmt = select(ServiceCenter).where(ServiceCenter.verification_status.in_(["pending", "needs_changes"] if status is None else [status]))
|
||
if city:
|
||
stmt = stmt.where(ServiceCenter.city == city)
|
||
rows = (await session.execute(stmt.order_by(ServiceCenter.created_at.asc()).limit(min(limit, 100)).offset(offset))).scalars()
|
||
return {"rows": [serialize_row(item, "sto_profiles", include_sensitive=False, role=current_user.platform_role) for item in rows]}
|
||
|
||
|
||
async def center_id_from_application(session: AsyncSession, application_id: int) -> int:
|
||
verification = await session.get(ServiceCenterVerification, application_id)
|
||
if verification is None:
|
||
center = await session.get(ServiceCenter, application_id)
|
||
if center is None:
|
||
raise HTTPException(status_code=404, detail="STO application not found")
|
||
return center.id
|
||
return verification.service_center_id
|
||
|
||
|
||
@router.post("/sto-applications/{application_id}/approve", response_model=ServiceCenterRead)
|
||
async def approve_sto_application(
|
||
application_id: int,
|
||
payload: AdminModerationDecision | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> ServiceCenter:
|
||
center_id = await center_id_from_application(session, application_id)
|
||
return await verify_service_center(center_id, payload, session, current_user)
|
||
|
||
|
||
@router.post("/sto-applications/{application_id}/reject", response_model=ServiceCenterRead)
|
||
async def reject_sto_application(
|
||
application_id: int,
|
||
payload: AdminModerationDecision | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> ServiceCenter:
|
||
center_id = await center_id_from_application(session, application_id)
|
||
return await reject_service_center(center_id, payload, session, current_user)
|
||
|
||
|
||
@router.post("/sto-applications/{application_id}/request-changes", response_model=ServiceCenterRead)
|
||
async def request_sto_application_changes(
|
||
application_id: int,
|
||
payload: AdminModerationDecision,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> ServiceCenter:
|
||
center_id = await center_id_from_application(session, application_id)
|
||
return await request_service_center_changes(center_id, payload, session, current_user)
|
||
|
||
|
||
@router.post("/sto/{service_center_id}/suspend", response_model=ServiceCenterRead)
|
||
async def suspend_sto(
|
||
service_center_id: int,
|
||
payload: AdminModerationDecision | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> ServiceCenter:
|
||
return await suspend_service_center(service_center_id, payload, session, current_user)
|
||
|
||
|
||
@router.post("/sto/{service_center_id}/unsuspend", response_model=ServiceCenterRead)
|
||
async def unsuspend_sto(
|
||
service_center_id: int,
|
||
payload: AdminModerationDecision | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> ServiceCenter:
|
||
require_admin_access(current_user, FULL_ADMIN_ROLES)
|
||
center = await session.get(ServiceCenter, service_center_id)
|
||
if center is None:
|
||
raise HTTPException(status_code=404, detail="Service center not found")
|
||
center.verification_status = "approved"
|
||
center.suspended_at = None
|
||
await create_admin_notification(
|
||
session,
|
||
event_type="sto_approved",
|
||
title="СТО разблокировано",
|
||
body=center.display_name or center.name,
|
||
entity_type="service_center",
|
||
entity_id=center.id,
|
||
idempotency_key=f"sto_unsuspended:{center.id}:{datetime.now(UTC).isoformat()}",
|
||
)
|
||
await log_audit(session, actor=current_user, action="service_center.unsuspend", target_type="service_center", target_id=center.id, metadata={"reason": payload.reason if payload else None})
|
||
await session.commit()
|
||
await session.refresh(center)
|
||
return center
|
||
|
||
|
||
@router.get("/service-centers/pending", response_model=list[ServiceCenterRead])
|
||
async def pending_service_centers(
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> list[ServiceCenter]:
|
||
require_admin_or_verifier(current_user)
|
||
result = await session.execute(
|
||
select(ServiceCenter)
|
||
.where(ServiceCenter.verification_status == "pending")
|
||
.order_by(ServiceCenter.created_at.asc())
|
||
)
|
||
return list(result.scalars())
|
||
|
||
|
||
@router.get("/service-centers/{service_center_id}", response_model=ServiceCenterRead)
|
||
async def admin_service_center_detail(
|
||
service_center_id: int,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> ServiceCenter:
|
||
require_admin_or_verifier(current_user)
|
||
center = await session.get(ServiceCenter, service_center_id)
|
||
if center is None:
|
||
raise HTTPException(status_code=404, detail="Service center not found")
|
||
return center
|
||
|
||
|
||
@router.post("/service-centers/{service_center_id}/verify", response_model=ServiceCenterRead)
|
||
async def verify_service_center(
|
||
service_center_id: int,
|
||
payload: AdminModerationDecision | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> ServiceCenter:
|
||
require_admin_or_verifier(current_user)
|
||
center = await session.get(ServiceCenter, service_center_id)
|
||
if center is None:
|
||
raise HTTPException(status_code=404, detail="Service center not found")
|
||
center.verification_status = "approved"
|
||
center.verified_at = datetime.now(UTC)
|
||
if center.owner_user_id:
|
||
owner = await session.get(User, center.owner_user_id)
|
||
if owner:
|
||
owner.platform_role = "service_owner"
|
||
await ensure_owner_employee(session, center.id, owner.id)
|
||
await notify_user(owner, f"Заявка СТО «{center.display_name or center.name}» одобрена. Панель СТО доступна в CarPass.")
|
||
await mark_latest_verification(session, center.id, "approved", current_user.id, payload)
|
||
await log_audit(
|
||
session,
|
||
actor=current_user,
|
||
action="service_center.verify",
|
||
target_type="service_center",
|
||
target_id=center.id,
|
||
metadata={"comment": payload.comment if payload else None},
|
||
)
|
||
await create_admin_notification(
|
||
session,
|
||
event_type="sto_approved",
|
||
title="СТО одобрено",
|
||
body=center.display_name or center.name,
|
||
entity_type="service_center",
|
||
entity_id=center.id,
|
||
idempotency_key=f"sto_approved:{center.id}:{center.verified_at.isoformat() if center.verified_at else 'now'}",
|
||
)
|
||
await session.commit()
|
||
await session.refresh(center)
|
||
return center
|
||
|
||
|
||
@router.post("/service-centers/{service_center_id}/reject", response_model=ServiceCenterRead)
|
||
async def reject_service_center(
|
||
service_center_id: int,
|
||
payload: AdminModerationDecision | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> ServiceCenter:
|
||
require_admin_or_verifier(current_user)
|
||
center = await session.get(ServiceCenter, service_center_id)
|
||
if center is None:
|
||
raise HTTPException(status_code=404, detail="Service center not found")
|
||
center.verification_status = "rejected"
|
||
if center.owner_user_id:
|
||
owner = await session.get(User, center.owner_user_id)
|
||
if owner:
|
||
reason = payload.reason or payload.comment if payload else None
|
||
await notify_user(owner, f"Заявка СТО «{center.display_name or center.name}» отклонена.{f' Причина: {reason}' if reason else ''}")
|
||
await mark_latest_verification(session, center.id, "rejected", current_user.id, payload)
|
||
await log_audit(
|
||
session,
|
||
actor=current_user,
|
||
action="service_center.reject",
|
||
target_type="service_center",
|
||
target_id=center.id,
|
||
metadata={"reason": payload.reason if payload else None, "comment": payload.comment if payload else None},
|
||
)
|
||
await create_admin_notification(
|
||
session,
|
||
event_type="sto_application_updated",
|
||
title="Заявка СТО отклонена",
|
||
body=center.display_name or center.name,
|
||
entity_type="service_center",
|
||
entity_id=center.id,
|
||
idempotency_key=f"sto_rejected:{center.id}:{datetime.now(UTC).isoformat()}",
|
||
)
|
||
await session.commit()
|
||
await session.refresh(center)
|
||
return center
|
||
|
||
|
||
@router.post("/service-centers/{service_center_id}/suspend", response_model=ServiceCenterRead)
|
||
async def suspend_service_center(
|
||
service_center_id: int,
|
||
payload: AdminModerationDecision | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> ServiceCenter:
|
||
require_admin_access(current_user, FULL_ADMIN_ROLES)
|
||
center = await session.get(ServiceCenter, service_center_id)
|
||
if center is None:
|
||
raise HTTPException(status_code=404, detail="Service center not found")
|
||
center.verification_status = "suspended"
|
||
center.suspended_at = datetime.now(UTC)
|
||
if center.owner_user_id:
|
||
owner = await session.get(User, center.owner_user_id)
|
||
if owner:
|
||
reason = payload.reason or payload.comment if payload else None
|
||
await notify_user(owner, f"СТО «{center.display_name or center.name}» временно заблокировано.{f' Причина: {reason}' if reason else ''}")
|
||
await log_audit(
|
||
session,
|
||
actor=current_user,
|
||
action="service_center.suspend",
|
||
target_type="service_center",
|
||
target_id=center.id,
|
||
metadata={"reason": payload.reason if payload else None, "comment": payload.comment if payload else None},
|
||
)
|
||
await create_admin_notification(
|
||
session,
|
||
event_type="sto_suspended",
|
||
title="СТО заблокировано",
|
||
body=center.display_name or center.name,
|
||
entity_type="service_center",
|
||
entity_id=center.id,
|
||
severity="warning",
|
||
idempotency_key=f"sto_suspended:{center.id}:{center.suspended_at.isoformat() if center.suspended_at else 'now'}",
|
||
)
|
||
await session.commit()
|
||
await session.refresh(center)
|
||
return center
|
||
|
||
|
||
@router.post("/service-centers/{service_center_id}/request-changes", response_model=ServiceCenterRead)
|
||
async def request_service_center_changes(
|
||
service_center_id: int,
|
||
payload: AdminModerationDecision,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> ServiceCenter:
|
||
require_admin_or_verifier(current_user)
|
||
center = await session.get(ServiceCenter, service_center_id)
|
||
if center is None:
|
||
raise HTTPException(status_code=404, detail="Service center not found")
|
||
center.verification_status = "needs_changes"
|
||
if center.owner_user_id:
|
||
owner = await session.get(User, center.owner_user_id)
|
||
if owner:
|
||
reason = payload.reason or payload.comment or "Администратор попросил уточнить данные заявки."
|
||
await notify_user(owner, f"По заявке СТО «{center.display_name or center.name}» нужны правки: {reason}")
|
||
await mark_latest_verification(session, center.id, "needs_changes", current_user.id, payload)
|
||
await log_audit(
|
||
session,
|
||
actor=current_user,
|
||
action="service_center.request_changes",
|
||
target_type="service_center",
|
||
target_id=center.id,
|
||
metadata={"reason": payload.reason, "comment": payload.comment},
|
||
)
|
||
await create_admin_notification(
|
||
session,
|
||
event_type="sto_application_updated",
|
||
title="По заявке СТО запрошены правки",
|
||
body=center.display_name or center.name,
|
||
entity_type="service_center",
|
||
entity_id=center.id,
|
||
idempotency_key=f"sto_changes:{center.id}:{datetime.now(UTC).isoformat()}",
|
||
)
|
||
await session.commit()
|
||
await session.refresh(center)
|
||
return center
|
||
|
||
|
||
@router.get("/audit-log")
|
||
async def audit_log(
|
||
actor_id: int | None = None,
|
||
actor_role: str | None = None,
|
||
action: str | None = None,
|
||
entity_type: str | None = None,
|
||
entity_id: str | None = None,
|
||
date_from: date | None = None,
|
||
date_to: date | None = None,
|
||
severity: str | None = None,
|
||
ip: str | None = None,
|
||
user_agent: str | None = None,
|
||
limit: int = 100,
|
||
offset: int = 0,
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> list[dict]:
|
||
require_admin_access(current_user, FULL_ADMIN_ROLES | {"moderator", "support"})
|
||
limit = min(max(limit, 1), 200)
|
||
stmt = select(AuditLog)
|
||
if actor_id is not None:
|
||
stmt = stmt.where(AuditLog.actor_user_id == actor_id)
|
||
if actor_role:
|
||
stmt = stmt.where(AuditLog.actor_role == actor_role)
|
||
if action:
|
||
stmt = stmt.where(AuditLog.action.ilike(f"%{action}%"))
|
||
if entity_type:
|
||
stmt = stmt.where(AuditLog.target_type == entity_type)
|
||
if entity_id:
|
||
stmt = stmt.where(AuditLog.target_id == entity_id)
|
||
if date_from:
|
||
stmt = stmt.where(func.date(AuditLog.created_at) >= date_from)
|
||
if date_to:
|
||
stmt = stmt.where(func.date(AuditLog.created_at) <= date_to)
|
||
if ip:
|
||
stmt = stmt.where(AuditLog.ip == ip)
|
||
if user_agent:
|
||
stmt = stmt.where(AuditLog.user_agent.ilike(f"%{user_agent}%"))
|
||
if severity:
|
||
stmt = stmt.where(AuditLog.metadata_json["severity"].as_string() == severity)
|
||
result = await session.execute(stmt.order_by(AuditLog.created_at.desc()).limit(limit).offset(max(offset, 0)))
|
||
await log_audit(
|
||
session,
|
||
actor=current_user,
|
||
action="admin.audit_log.view",
|
||
target_type="audit_log",
|
||
metadata={"limit": limit, "offset": offset, "filter_action": action},
|
||
)
|
||
await session.commit()
|
||
return [
|
||
{
|
||
"id": item.id,
|
||
"actor_user_id": item.actor_user_id,
|
||
"actor_role": item.actor_role,
|
||
"action": item.action,
|
||
"target_type": item.target_type,
|
||
"target_id": item.target_id,
|
||
"metadata_json": item.metadata_json,
|
||
"created_at": item.created_at,
|
||
}
|
||
for item in result.scalars()
|
||
]
|
||
|
||
|
||
@router.get("/disputes", response_model=list[ServiceVisitRead])
|
||
async def disputes(
|
||
session: AsyncSession = Depends(get_session),
|
||
current_user: User = Depends(get_current_telegram_user),
|
||
) -> list[ServiceVisit]:
|
||
require_admin_or_verifier(current_user)
|
||
result = await session.execute(
|
||
select(ServiceVisit).where(ServiceVisit.status == "disputed").order_by(ServiceVisit.updated_at.desc())
|
||
)
|
||
return list(result.scalars())
|
||
|
||
|
||
async def mark_latest_verification(
|
||
session: AsyncSession,
|
||
service_center_id: int,
|
||
status: str,
|
||
reviewed_by: int,
|
||
payload: AdminModerationDecision | None = None,
|
||
) -> None:
|
||
result = await session.execute(
|
||
select(ServiceCenterVerification)
|
||
.where(ServiceCenterVerification.service_center_id == service_center_id)
|
||
.order_by(ServiceCenterVerification.created_at.desc())
|
||
.limit(1)
|
||
)
|
||
verification = result.scalar_one_or_none()
|
||
if verification:
|
||
verification.status = status
|
||
verification.reviewed_by = reviewed_by
|
||
verification.reviewed_at = datetime.now(UTC)
|
||
if payload and (payload.reason or payload.comment):
|
||
verification.comment = "\n".join(
|
||
item for item in [payload.reason, payload.comment] if item
|
||
)
|
||
|
||
|
||
async def ensure_owner_employee(session: AsyncSession, service_center_id: int, owner_user_id: int) -> None:
|
||
result = await session.execute(
|
||
select(ServiceEmployee).where(
|
||
ServiceEmployee.service_center_id == service_center_id,
|
||
ServiceEmployee.user_id == owner_user_id,
|
||
)
|
||
)
|
||
employee = result.scalar_one_or_none()
|
||
if employee is None:
|
||
session.add(
|
||
ServiceEmployee(
|
||
service_center_id=service_center_id,
|
||
user_id=owner_user_id,
|
||
role="owner",
|
||
status="active",
|
||
)
|
||
)
|
||
else:
|
||
employee.role = "owner"
|
||
employee.status = "active"
|