admin notifications and data explorer backend
This commit is contained in:
887
app/api/admin.py
887
app/api/admin.py
@@ -1,27 +1,826 @@
|
||||
from datetime import UTC, datetime
|
||||
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 sqlalchemy import select
|
||||
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, {"admin", "verifier", "moderator"})
|
||||
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])
|
||||
@@ -79,6 +878,15 @@ async def verify_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
|
||||
@@ -110,6 +918,15 @@ async def reject_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
|
||||
@@ -122,7 +939,7 @@ async def suspend_service_center(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceCenter:
|
||||
require_platform_role(current_user, {"admin"})
|
||||
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")
|
||||
@@ -141,6 +958,16 @@ async def suspend_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
|
||||
@@ -172,6 +999,15 @@ async def request_service_center_changes(
|
||||
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
|
||||
@@ -179,16 +1015,53 @@ async def request_service_center_changes(
|
||||
|
||||
@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_platform_role(current_user, {"admin", "verifier", "moderator"})
|
||||
require_admin_access(current_user, FULL_ADMIN_ROLES | {"moderator", "support"})
|
||||
limit = min(max(limit, 1), 200)
|
||||
result = await session.execute(
|
||||
select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit).offset(max(offset, 0))
|
||||
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,
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.models.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess
|
||||
from app.models.user import User
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.telegram_auth import verify_webapp_init_data
|
||||
|
||||
|
||||
@@ -36,6 +37,24 @@ async def get_or_create_telegram_user(
|
||||
if user is None:
|
||||
user = User(**{key: value for key, value in payload.items() if value is not None})
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="user_registered",
|
||||
title="Новый пользователь",
|
||||
body="\n".join(
|
||||
item
|
||||
for item in [
|
||||
f"Имя: {' '.join(part for part in [first_name, last_name] if part) or '-'}",
|
||||
f"Telegram ID: {telegram_id}",
|
||||
f"Username: @{username}" if username else "Username: -",
|
||||
]
|
||||
),
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
idempotency_key=f"user_registered:{telegram_id}",
|
||||
metadata={"telegram_id": telegram_id, "username": username},
|
||||
)
|
||||
else:
|
||||
for field, value in payload.items():
|
||||
if value is not None:
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.schemas.service_center import (
|
||||
VehicleUpdate,
|
||||
)
|
||||
from app.schemas.user import UserRead
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.odometer import (
|
||||
add_odometer_history,
|
||||
recalculate_current_odometer,
|
||||
@@ -381,6 +382,20 @@ async def create_vehicle(
|
||||
changed_by=current_user.id,
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
|
||||
vehicle_count = (
|
||||
await session.execute(select(Car.id).where(Car.owner_id == current_user.id).limit(2))
|
||||
).scalars().all()
|
||||
if len(vehicle_count) == 1:
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="vehicle_created",
|
||||
title="Пользователь впервые добавил авто",
|
||||
body=f"{current_user.first_name or current_user.username or current_user.telegram_id}: {car.name}",
|
||||
entity_type="vehicle",
|
||||
entity_id=car.id,
|
||||
idempotency_key=f"first_vehicle:{current_user.id}",
|
||||
metadata={"user_id": current_user.id, "vehicle_id": car.id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(car)
|
||||
return car
|
||||
|
||||
@@ -48,6 +48,7 @@ from app.schemas.service_center import (
|
||||
VehicleSearchRequest,
|
||||
VehicleSearchResult,
|
||||
)
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.notifications import notify_platform_moderators
|
||||
from app.services.odometer import validate_odometer_change
|
||||
from app.services.rate_limit import check_rate_limit
|
||||
@@ -147,6 +148,25 @@ async def create_service_center(
|
||||
target_type="service_center",
|
||||
target_id=center.id,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="sto_application_created",
|
||||
title="Новая заявка СТО",
|
||||
body="\n".join(
|
||||
item
|
||||
for item in [
|
||||
f"Название: {center.display_name or center.name}",
|
||||
f"Город: {center.city or '-'}",
|
||||
f"Телефон: {center.phone or center.contact_phone or '-'}",
|
||||
f"Документы: {len(center.document_photo_urls or [])}",
|
||||
"Статус: pending",
|
||||
]
|
||||
),
|
||||
entity_type="service_center",
|
||||
entity_id=center.id,
|
||||
idempotency_key=f"sto_application_created:{center.id}",
|
||||
metadata={"city": center.city, "owner_user_id": current_user.id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(center)
|
||||
await notify_platform_moderators(
|
||||
|
||||
Reference in New Issue
Block a user