From fa703acce12aeb0368a9e1df6bc28b8ce7b4a8d7 Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Sun, 17 May 2026 21:16:22 +0900 Subject: [PATCH 1/6] admin notifications and data explorer backend --- .env.example | 5 + ...70001_admin_notifications_data_explorer.py | 85 ++ app/api/admin.py | 887 +++++++++++++++++- app/api/deps.py | 19 + app/api/my.py | 15 + app/api/service_centers.py | 20 + app/core/config.py | 5 + app/models/car.py | 39 + app/services/admin_notifications.py | 150 +++ 9 files changed, 1218 insertions(+), 7 deletions(-) create mode 100644 alembic/versions/202605170001_admin_notifications_data_explorer.py create mode 100644 app/services/admin_notifications.py diff --git a/.env.example b/.env.example index 5d8d1c4..98bbc09 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,8 @@ OCR_PROVIDER=tesseract OCR_LANGUAGES=eng+rus+kor ADMIN_TELEGRAM_IDS= ADMIN_BOOTSTRAP_TOKEN= +ADMIN_NOTIFICATION_CHAT_ID= +ADMIN_NOTIFY_NEW_USERS=true +ADMIN_NOTIFY_STO_APPLICATIONS=true +ADMIN_NOTIFY_SECURITY_EVENTS=true +ADMIN_NOTIFY_SYSTEM_ERRORS=true diff --git a/alembic/versions/202605170001_admin_notifications_data_explorer.py b/alembic/versions/202605170001_admin_notifications_data_explorer.py new file mode 100644 index 0000000..9a48616 --- /dev/null +++ b/alembic/versions/202605170001_admin_notifications_data_explorer.py @@ -0,0 +1,85 @@ +"""admin notifications and data explorer jobs + +Revision ID: 202605170001 +Revises: 202605160002 +Create Date: 2026-05-17 00:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "202605170001" +down_revision: str | None = "202605160002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "admin_notifications", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("event_type", sa.String(length=80), nullable=False), + sa.Column("severity", sa.String(length=24), server_default="info", nullable=False), + sa.Column("title", sa.String(length=180), nullable=False), + sa.Column("body", sa.Text(), nullable=True), + sa.Column("entity_type", sa.String(length=80), nullable=True), + sa.Column("entity_id", sa.String(length=80), nullable=True), + sa.Column("status", sa.String(length=24), server_default="unread", nullable=False), + sa.Column("idempotency_key", sa.String(length=180), nullable=False), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column("telegram_status", sa.String(length=24), server_default="pending", nullable=False), + sa.Column("telegram_error", sa.Text(), nullable=True), + sa.Column("read_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("dismissed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_admin_notifications_created_at", "admin_notifications", ["created_at"]) + op.create_index("ix_admin_notifications_entity_id", "admin_notifications", ["entity_id"]) + op.create_index("ix_admin_notifications_entity_type", "admin_notifications", ["entity_type"]) + op.create_index("ix_admin_notifications_event_type", "admin_notifications", ["event_type"]) + op.create_index("ix_admin_notifications_idempotency_key", "admin_notifications", ["idempotency_key"], unique=True) + op.create_index("ix_admin_notifications_severity", "admin_notifications", ["severity"]) + op.create_index("ix_admin_notifications_status", "admin_notifications", ["status"]) + op.create_index("ix_admin_notifications_telegram_status", "admin_notifications", ["telegram_status"]) + + op.create_table( + "admin_export_jobs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("requested_by_user_id", sa.Integer(), nullable=True), + sa.Column("source", sa.String(length=80), nullable=False), + sa.Column("export_format", sa.String(length=16), server_default="json", nullable=False), + sa.Column("status", sa.String(length=24), server_default="ready", nullable=False), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("filters_json", sa.JSON(), nullable=True), + sa.Column("result_text", sa.Text(), nullable=True), + sa.Column("row_count", sa.Integer(), server_default="0", nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_admin_export_jobs_created_at", "admin_export_jobs", ["created_at"]) + op.create_index("ix_admin_export_jobs_requested_by_user_id", "admin_export_jobs", ["requested_by_user_id"]) + op.create_index("ix_admin_export_jobs_source", "admin_export_jobs", ["source"]) + op.create_index("ix_admin_export_jobs_status", "admin_export_jobs", ["status"]) + + +def downgrade() -> None: + op.drop_index("ix_admin_export_jobs_status", table_name="admin_export_jobs") + op.drop_index("ix_admin_export_jobs_source", table_name="admin_export_jobs") + op.drop_index("ix_admin_export_jobs_requested_by_user_id", table_name="admin_export_jobs") + op.drop_index("ix_admin_export_jobs_created_at", table_name="admin_export_jobs") + op.drop_table("admin_export_jobs") + op.drop_index("ix_admin_notifications_telegram_status", table_name="admin_notifications") + op.drop_index("ix_admin_notifications_status", table_name="admin_notifications") + op.drop_index("ix_admin_notifications_severity", table_name="admin_notifications") + op.drop_index("ix_admin_notifications_idempotency_key", table_name="admin_notifications") + op.drop_index("ix_admin_notifications_event_type", table_name="admin_notifications") + op.drop_index("ix_admin_notifications_entity_type", table_name="admin_notifications") + op.drop_index("ix_admin_notifications_entity_id", table_name="admin_notifications") + op.drop_index("ix_admin_notifications_created_at", table_name="admin_notifications") + op.drop_table("admin_notifications") diff --git a/app/api/admin.py b/app/api/admin.py index 8bb15ba..96c696b 100644 --- a/app/api/admin.py +++ b/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, diff --git a/app/api/deps.py b/app/api/deps.py index ff68f56..b94b500 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -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: diff --git a/app/api/my.py b/app/api/my.py index 0d56b3e..eb807a5 100644 --- a/app/api/my.py +++ b/app/api/my.py @@ -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 diff --git a/app/api/service_centers.py b/app/api/service_centers.py index 584c202..898290d 100644 --- a/app/api/service_centers.py +++ b/app/api/service_centers.py @@ -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( diff --git a/app/core/config.py b/app/core/config.py index 4e12116..7f8edec 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -24,6 +24,11 @@ class Settings(BaseSettings): ocr_languages: str = "eng+rus+kor" admin_telegram_ids: str = "" admin_bootstrap_token: str = "" + admin_notification_chat_id: str = "" + admin_notify_new_users: bool = True + admin_notify_sto_applications: bool = True + admin_notify_security_events: bool = True + admin_notify_system_errors: bool = True model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") diff --git a/app/models/car.py b/app/models/car.py index b4098d2..7e09f9b 100644 --- a/app/models/car.py +++ b/app/models/car.py @@ -432,6 +432,29 @@ class ServiceNotification(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) +class AdminNotification(Base): + __tablename__ = "admin_notifications" + + id: Mapped[int] = mapped_column(primary_key=True) + event_type: Mapped[str] = mapped_column(String(80), index=True) + severity: Mapped[str] = mapped_column(String(24), default="info", server_default="info", index=True) + title: Mapped[str] = mapped_column(String(180)) + body: Mapped[str | None] = mapped_column(Text) + entity_type: Mapped[str | None] = mapped_column(String(80), index=True) + entity_id: Mapped[str | None] = mapped_column(String(80), index=True) + status: Mapped[str] = mapped_column(String(24), default="unread", server_default="unread", index=True) + idempotency_key: Mapped[str] = mapped_column(String(180), unique=True, index=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON) + telegram_status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True) + telegram_error: Mapped[str | None] = mapped_column(Text) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + class ServiceWorkItem(Base): __tablename__ = "service_work_items" @@ -625,3 +648,19 @@ class AuditLog(Base): user_agent: Mapped[str | None] = mapped_column(String(256)) metadata_json: Mapped[dict | None] = mapped_column(JSON) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + + +class AdminExportJob(Base): + __tablename__ = "admin_export_jobs" + + id: Mapped[int] = mapped_column(primary_key=True) + requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True) + source: Mapped[str] = mapped_column(String(80), index=True) + export_format: Mapped[str] = mapped_column(String(16), default="json", server_default="json") + status: Mapped[str] = mapped_column(String(24), default="ready", server_default="ready", index=True) + reason: Mapped[str | None] = mapped_column(Text) + filters_json: Mapped[dict | None] = mapped_column(JSON) + result_text: Mapped[str | None] = mapped_column(Text) + row_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) diff --git a/app/services/admin_notifications.py b/app/services/admin_notifications.py new file mode 100644 index 0000000..35e6d94 --- /dev/null +++ b/app/services/admin_notifications.py @@ -0,0 +1,150 @@ +import logging +from datetime import UTC, datetime +from html import escape + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.models.car import AdminNotification + +logger = logging.getLogger(__name__) + + +ADMIN_EVENT_FLAGS = { + "user_registered": "admin_notify_new_users", + "vehicle_created": "admin_notify_new_users", + "first_record_created": "admin_notify_new_users", + "sto_application_created": "admin_notify_sto_applications", + "sto_application_updated": "admin_notify_sto_applications", + "sto_approved": "admin_notify_sto_applications", + "sto_suspended": "admin_notify_sto_applications", + "security_event": "admin_notify_security_events", + "rate_limit_exceeded": "admin_notify_security_events", + "upload_blocked": "admin_notify_security_events", + "system_error": "admin_notify_system_errors", + "ocr_failed": "admin_notify_system_errors", +} + + +def admin_event_enabled(event_type: str) -> bool: + flag = ADMIN_EVENT_FLAGS.get(event_type) + return bool(getattr(settings, flag, True)) if flag else True + + +def admin_recipients() -> list[str]: + recipients: list[str] = [] + if settings.admin_notification_chat_id: + recipients.append(settings.admin_notification_chat_id) + recipients.extend(str(item) for item in settings.admin_telegram_id_list) + return list(dict.fromkeys(recipients)) + + +def admin_notification_url(entity_type: str | None = None, entity_id: str | int | None = None) -> str: + base = settings.effective_webapp_url + if entity_type == "service_center" and entity_id: + return f"{base}/admin.html?section=sto-applications&entity_id={entity_id}" + if entity_type == "user" and entity_id: + return f"{base}/admin.html?section=users&entity_id={entity_id}" + return f"{base}/admin.html" + + +async def create_admin_notification( + session: AsyncSession, + *, + event_type: str, + title: str, + body: str | None = None, + entity_type: str | None = None, + entity_id: int | str | None = None, + severity: str = "info", + idempotency_key: str | None = None, + metadata: dict | None = None, + send_telegram: bool = True, +) -> AdminNotification: + key = idempotency_key or f"{event_type}:{entity_type or 'system'}:{entity_id or title}" + existing = ( + await session.execute(select(AdminNotification).where(AdminNotification.idempotency_key == key)) + ).scalar_one_or_none() + if existing: + return existing + + notification = AdminNotification( + event_type=event_type, + title=title, + body=body, + entity_type=entity_type, + entity_id=str(entity_id) if entity_id is not None else None, + severity=severity, + idempotency_key=key, + metadata_json=metadata, + telegram_status="pending" if send_telegram else "skipped", + ) + session.add(notification) + await session.flush() + + if send_telegram and admin_event_enabled(event_type): + await send_admin_telegram_notification(notification) + elif send_telegram: + notification.telegram_status = "skipped" + return notification + + +async def send_admin_telegram_notification(notification: AdminNotification) -> None: + recipients = admin_recipients() + if not recipients or not settings.bot_token: + notification.telegram_status = "skipped" + return + + link = admin_notification_url(notification.entity_type, notification.entity_id) + text = "\n".join( + item + for item in [ + f"{escape(notification.title)}", + escape(notification.body or ""), + f"Событие: {escape(notification.event_type)}", + f"Открыть: {escape(link)}", + ] + if item + ) + errors: list[str] = [] + async with httpx.AsyncClient(timeout=8) as client: + for chat_id in recipients: + try: + response = await client.post( + f"https://api.telegram.org/bot{settings.bot_token}/sendMessage", + json={ + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": True, + }, + ) + response.raise_for_status() + except Exception as error: # noqa: BLE001 - notification delivery is best-effort + logger.warning("Admin Telegram notification failed: %s", error) + errors.append(str(error)) + if errors: + notification.telegram_status = "failed" + notification.telegram_error = "; ".join(errors)[:2000] + else: + notification.telegram_status = "sent" + + +async def mark_admin_notification_read( + session: AsyncSession, notification: AdminNotification +) -> AdminNotification: + notification.status = "read" + notification.read_at = datetime.now(UTC) + await session.flush() + return notification + + +async def dismiss_admin_notification( + session: AsyncSession, notification: AdminNotification +) -> AdminNotification: + notification.status = "dismissed" + notification.dismissed_at = datetime.now(UTC) + await session.flush() + return notification -- 2.49.1 From 0f6d6e31e1c6fee21ff02a445db9aba759056a7f Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Sun, 17 May 2026 21:16:28 +0900 Subject: [PATCH 2/6] add admin control center ui and bot commands --- ADMIN.md | 155 ++++++++++++++++++ README.md | 6 + bot/api_client.py | 9 + bot/main.py | 80 +++++++++ web/admin.html | 241 +++++++++++++++++++++++++++ web/static/admin.js | 371 ++++++++++++++++++++++++++++++++++++++++++ web/static/styles.css | 174 ++++++++++++++++++++ 7 files changed, 1036 insertions(+) create mode 100644 ADMIN.md create mode 100644 web/admin.html create mode 100644 web/static/admin.js diff --git a/ADMIN.md b/ADMIN.md new file mode 100644 index 0000000..fc42566 --- /dev/null +++ b/ADMIN.md @@ -0,0 +1,155 @@ +# CarPass Admin Control Center + +Admin Control Center дает администраторам закрытого пилота безопасный доступ к событиям сервиса, модерации СТО, просмотру данных и экспорту без прямого SQL. + +## Доступ + +Админка открывается в Mini App по `/admin.html` или командой бота `/admin`. + +Роли: + +- `super_admin`: полный доступ к пользователям, СТО, заявкам, заказ-нарядам, расходам, audit, export и системным настройкам. +- `admin`: пользователи, СТО, модерация, заказ-наряды, базовая аналитика и экспорт без секретов. +- `moderator`: заявки СТО, отзывы, блокировки и комментарии модерации. +- `support`: поиск пользователя, авто, история действий и помощь без расширенных финансовых агрегатов. +- `analyst`: агрегированная аналитика и обезличенные выгрузки без персональных данных. + +Все чувствительные admin actions пишутся в `AuditLog`. + +## Уведомления + +Система создает `AdminNotification` в БД и best-effort отправляет Telegram-сообщение администраторам. Ошибка Telegram не ломает бизнес-flow. + +Поддержанные события: + +- новый пользователь; +- первое авто пользователя; +- новая заявка СТО; +- изменение статуса заявки СТО; +- одобрение, блокировка и разблокировка СТО; +- security/system события через общий admin notification service. + +Idempotency key защищает от дублей. + +Env: + +```env +ADMIN_TELEGRAM_IDS=123,456 +ADMIN_NOTIFICATION_CHAT_ID= +ADMIN_NOTIFY_NEW_USERS=true +ADMIN_NOTIFY_STO_APPLICATIONS=true +ADMIN_NOTIFY_SECURITY_EVENTS=true +ADMIN_NOTIFY_SYSTEM_ERRORS=true +``` + +## Admin API + +Dashboard: + +- `GET /api/admin/dashboard` + +Notifications: + +- `GET /api/admin/notifications` +- `POST /api/admin/notifications/{id}/read` +- `POST /api/admin/notifications/read-all` +- `POST /api/admin/notifications/{id}/dismiss` + +Data Explorer: + +- `GET /api/admin/data/sources` +- `POST /api/admin/data/query` +- `POST /api/admin/data/export` + +Users: + +- `GET /api/admin/users` +- `GET /api/admin/users/{id}` +- `GET /api/admin/users/{id}/activity` +- `POST /api/admin/users/{id}/note` +- `POST /api/admin/users/{id}/block` +- `POST /api/admin/users/{id}/unblock` + +СТО: + +- `GET /api/admin/sto` +- `GET /api/admin/sto/{id}` +- `GET /api/admin/sto-applications` +- `POST /api/admin/sto-applications/{id}/approve` +- `POST /api/admin/sto-applications/{id}/reject` +- `POST /api/admin/sto-applications/{id}/request-changes` +- `POST /api/admin/sto/{id}/suspend` +- `POST /api/admin/sto/{id}/unsuspend` + +Audit and exports: + +- `GET /api/admin/audit-log` +- `GET /api/admin/exports` +- `GET /api/admin/exports/{id}` + +## Data Explorer + +Data Explorer работает только по whitelist источников и полей. Произвольный SQL из UI не принимается. + +Источники: + +- `users` +- `vehicles` +- `fuel_entries` +- `service_entries` +- `expense_entries` +- `sto_profiles` +- `sto_applications` +- `sto_employees` +- `vehicle_sto_links` +- `appointments` +- `work_orders` +- `work_order_items` +- `work_order_products` +- `reviews` +- `notifications` +- `admin_notifications` +- `audit_logs` +- `imports_exports` + +Поддержаны фильтры по дате, статусу, пользователю, Telegram ID, авто, СТО, городу, роли, категории, сумме, ошибкам и текстовому поиску. Каждый запрос ограничен `limit` до 500 строк и пишет audit log. + +## Privacy + +По умолчанию маскируются Telegram ID, VIN, госномер, телефон и регистрационные данные СТО. + +Полный просмотр sensitive data: + +- доступен только `admin` и `super_admin`; +- требует `reason`; +- пишет audit log; +- не раскрывает bot token, env, internal token, secret fields. + +`analyst` видит только обезличенные или замаскированные персональные данные. + +## Модерация СТО + +Очередь заявок доступна в `/admin.html?section=sto-applications`. + +Действия: + +- approve; +- reject with reason; +- request changes with reason; +- suspend; +- unsuspend. + +При изменении статуса создаются audit log, admin notification и уведомление владельцу СТО. + +## Bot Commands + +Админские команды бота: + +- `/admin` +- `/admin_stats` +- `/admin_users` +- `/admin_sto` +- `/admin_pending_sto` +- `/admin_alerts` + +API дополнительно проверяет роль пользователя, поэтому команда не дает доступа без admin-role в БД. diff --git a/README.md b/README.md index df68495..ca992c8 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,12 @@ CarPass создает рекомендации обслуживания из д Telegram-уведомления являются основным каналом закрытого пилота. Browser push уже умеет сохранять подписки в Mini App и принимать push-события в service worker, но серверная Web Push-доставка помечена как beta и не считается критическим каналом пилота. +## Администрирование + +Admin Control Center доступен по `/admin.html` и через команды бота `/admin`, `/admin_stats`, `/admin_users`, `/admin_sto`, `/admin_pending_sto`, `/admin_alerts`. + +Админка включает dashboard сервиса, admin notifications, очередь заявок СТО, пользователей, автомобили, записи, заказ-наряды, audit log, экспорт и безопасный Data Explorer без произвольного SQL. Подробности по ролям, privacy, env и API описаны в [ADMIN.md](ADMIN.md). + ## Безопасность данных CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца. diff --git a/bot/api_client.py b/bot/api_client.py index 356fcb4..d0e7889 100644 --- a/bot/api_client.py +++ b/bot/api_client.py @@ -126,6 +126,15 @@ class ApiClient: async def pending_service_centers(self, telegram_id: int) -> list[dict[str, Any]]: return await self.request("GET", "/api/admin/service-centers/pending", telegram_id=telegram_id) + async def admin_dashboard(self, telegram_id: int) -> dict[str, Any]: + return await self.request("GET", "/api/admin/dashboard", telegram_id=telegram_id) + + async def admin_users(self, telegram_id: int) -> dict[str, Any]: + return await self.request("GET", "/api/admin/users", telegram_id=telegram_id, params={"limit": 10}) + + async def admin_alerts(self, telegram_id: int) -> dict[str, Any]: + return await self.request("GET", "/api/admin/notifications", telegram_id=telegram_id, params={"limit": 10}) + async def moderate_service_center( self, telegram_id: int, diff --git a/bot/main.py b/bot/main.py index 571b713..15f4e5b 100644 --- a/bot/main.py +++ b/bot/main.py @@ -433,6 +433,7 @@ async def register_sto(message: Message, command: CommandObject) -> None: @dp.message(Command("admin_sto_pending")) +@dp.message(Command("admin_pending_sto")) async def admin_sto_pending(message: Message) -> None: await upsert(message) try: @@ -459,6 +460,77 @@ async def admin_sto_pending(message: Message) -> None: await message.answer(text, reply_markup=admin_card_keyboard(center["id"])) +@dp.message(Command("admin")) +async def admin_home(message: Message) -> None: + await upsert(message) + try: + await api.admin_dashboard(message.from_user.id) + except httpx.HTTPStatusError as error: + await message.answer(f"Админка недоступна: {error.response.text}") + return + await message.answer( + "Admin Control Center: уведомления, пользователи, СТО, заявки, Data Explorer и Audit Log.", + reply_markup=webapp_inline_keyboard("Открыть админку", "admin.html"), + ) + + +@dp.message(Command("admin_stats")) +async def admin_stats(message: Message) -> None: + await upsert(message) + try: + dashboard = await api.admin_dashboard(message.from_user.id) + except httpx.HTTPStatusError as error: + await message.answer(f"Нет доступа к admin stats: {error.response.text}") + return + await message.answer( + "\n".join( + [ + "Admin stats", + f"Users today: {dashboard['users_today']}", + f"Users total: {dashboard['users_total']}", + f"STO pending: {dashboard['pending_sto_applications']}", + f"Appointments today: {dashboard['appointments_today']}", + f"Work orders active: {dashboard['active_work_orders']}", + f"Errors/security: {dashboard['system_errors']} / {dashboard['security_events']}", + ] + ), + reply_markup=webapp_inline_keyboard("Admin dashboard", "admin.html"), + ) + + +@dp.message(Command("admin_users")) +async def admin_users(message: Message) -> None: + await upsert(message) + try: + data = await api.admin_users(message.from_user.id) + except httpx.HTTPStatusError as error: + await message.answer(f"Нет доступа к admin users: {error.response.text}") + return + lines = ["Последние пользователи:"] + for row in data.get("rows", [])[:10]: + lines.append(f"#{row.get('id')} {row.get('username') or '-'} · {row.get('platform_role')} · {row.get('created_at')}") + await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Users", "admin.html?section=users")) + + +@dp.message(Command("admin_sto")) +async def admin_sto(message: Message) -> None: + await admin_sto_pending(message) + + +@dp.message(Command("admin_alerts")) +async def admin_alerts(message: Message) -> None: + await upsert(message) + try: + data = await api.admin_alerts(message.from_user.id) + except httpx.HTTPStatusError as error: + await message.answer(f"Нет доступа к admin alerts: {error.response.text}") + return + lines = ["Admin alerts:"] + for row in data.get("rows", [])[:10]: + lines.append(f"#{row.get('id')} {row.get('severity')} · {row.get('title')} · {row.get('status')}") + await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Alerts", "admin.html?section=notifications")) + + async def admin_action(message: Message, command: CommandObject, action: str) -> None: args = (command.args or "").split(maxsplit=1) if not args: @@ -577,7 +649,14 @@ async def admin_callback(callback: CallbackQuery) -> None: @dp.message(F.text == "Помощь") @dp.message(Command("help")) async def help_message(message: Message) -> None: + user = await api.upsert_user(message.from_user) centers = await sto_workplace_centers(message.from_user.id) + admin_help = ( + "Админ: /admin — панель, /admin_stats — метрики, /admin_users — последние пользователи, " + "/admin_pending_sto — заявки СТО, /admin_alerts — события.\n" + if user.get("platform_role") in {"admin", "super_admin", "moderator", "support", "analyst"} + else "" + ) sto_workplace_help = ( "• /sto_bookings или /sto_workplace — панель подтвержденного СТО;\n" "• /accept_sto_invite — принять приглашение сотрудника;\n" @@ -601,6 +680,7 @@ async def help_message(message: Message) -> None: "• /sto — каталог проверенных СТО;\n" "• /appointments — мои записи в СТО;\n" f"{sto_workplace_help}" + f"{admin_help}" "\n" "Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n" f"{sto_business_help}" diff --git a/web/admin.html b/web/admin.html new file mode 100644 index 0000000..b7b8856 --- /dev/null +++ b/web/admin.html @@ -0,0 +1,241 @@ + + + + + + + Admin Control Center + + + + + +
+
+

CarPass

+

Админ-панель

+

Откройте страницу через Telegram, чтобы подтвердить права администратора.

+
+ + +
+
+
+ +
+
+
+

CarPass Admin

+

Control Center

+
+
+ +
+
+ +
+
+
+

Пилотный контур

+

Операционный обзор

+ Загружаю доступ и источники данных... +
+ Проверка +
+
+ + + +
+
+
+

Сервис

+

Dashboard

+
+
+
+
+
+

Последние события

+
+
+
+

Быстрые переходы

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/web/static/admin.js b/web/static/admin.js new file mode 100644 index 0000000..dac167d --- /dev/null +++ b/web/static/admin.js @@ -0,0 +1,371 @@ +const AdminPage = (() => { + const { api, boot, toast, escapeHtml, formData, formatDateTime } = CarPassPage; + const state = { + active: "dashboard", + sources: [], + sorts: [], + lastDataPayload: null, + }; + + const panels = { + dashboard: "#panel-dashboard", + notifications: "#panel-notifications", + users: "#panel-users", + sto: "#panel-sto", + "sto-applications": "#panel-sto-applications", + vehicles: "#panel-vehicles", + appointments: "#panel-appointments", + "work-orders": "#panel-work-orders", + data: "#panel-data", + audit: "#panel-audit", + exports: "#panel-exports", + }; + + const quickLinks = [ + ["notifications", "Notifications"], + ["users", "Users"], + ["sto-applications", "Заявки СТО"], + ["vehicles", "Авто"], + ["data", "Data Explorer"], + ["audit", "Audit Log"], + ]; + + function qs(selector) { + return document.querySelector(selector); + } + + function valueOrDash(value) { + if (value === null || value === undefined || value === "") return "-"; + if (typeof value === "string" && value.includes("T")) return formatDateTime(value); + return escapeHtml(value); + } + + function setActive(section) { + state.active = panels[section] ? section : "dashboard"; + Object.entries(panels).forEach(([name, selector]) => { + qs(selector)?.classList.toggle("hidden", name !== state.active); + }); + document.querySelectorAll("[data-admin-tab]").forEach((button) => { + button.classList.toggle("active", button.dataset.adminTab === state.active); + }); + const url = new URL(window.location.href); + url.searchParams.set("section", state.active); + window.history.replaceState({}, "", url); + } + + function renderEmpty(root, text = "Нет данных") { + root.innerHTML = `
${escapeHtml(text)}
`; + } + + function renderError(root, error) { + root.innerHTML = `
${escapeHtml(error.message || "Ошибка")}
`; + } + + function renderTable(root, rows, preferredColumns = []) { + if (!rows?.length) { + renderEmpty(root); + return; + } + const columns = preferredColumns.length ? preferredColumns : Object.keys(rows[0]); + root.innerHTML = ` + + + ${columns.map((column) => ``).join("")} + + + ${rows + .map( + (row) => ` + + ${columns.map((column) => ``).join("")} + + `, + ) + .join("")} + +
${escapeHtml(column)}
${valueOrDash(row[column])}
+ `; + } + + function badge(value) { + return `${escapeHtml(value || "-")}`; + } + + async function loadDashboard() { + const data = await api("/admin/dashboard"); + const statLabels = [ + ["users_today", "Новые сегодня"], + ["users_7d", "Новые 7 дней"], + ["users_total", "Всего пользователей"], + ["active_users", "Активные"], + ["vehicles_total", "Авто"], + ["pending_sto_applications", "Pending СТО"], + ["approved_sto", "Approved СТО"], + ["appointments_today", "Записи сегодня"], + ["active_work_orders", "Активные ЗН"], + ["completed_work_orders", "Завершенные ЗН"], + ["system_errors", "Ошибки"], + ["security_events", "Security"], + ]; + qs("#dashboardStats").innerHTML = statLabels + .map(([key, label]) => `
${label}${data[key] ?? 0}
`) + .join(""); + const alerts = qs("#dashboardAlerts"); + alerts.innerHTML = data.latest_alerts?.length + ? data.latest_alerts + .map( + (item) => ` +
+
+ ${escapeHtml(item.title)} + ${badge(item.event_type)} ${formatDateTime(item.created_at)} +
+
+ `, + ) + .join("") + : `
Критичных событий нет
`; + qs("#quickLinks").innerHTML = quickLinks + .map(([section, label]) => ``) + .join(""); + bindTabButtons(); + } + + async function loadNotifications() { + const root = qs("#notificationsList"); + try { + const data = await api("/admin/notifications?limit=100"); + if (!data.rows.length) return renderEmpty(root); + root.innerHTML = data.rows + .map( + (item) => ` +
+
+ ${escapeHtml(item.title)} + ${badge(item.event_type)} ${badge(item.severity)} ${badge(item.status)} ${formatDateTime(item.created_at)} +

${escapeHtml(item.body || "")}

+
+
+ + +
+
+ `, + ) + .join(""); + root.querySelectorAll("[data-read-notification]").forEach((button) => { + button.addEventListener("click", async () => { + await api(`/admin/notifications/${button.dataset.readNotification}/read`, { method: "POST" }); + await loadNotifications(); + }); + }); + root.querySelectorAll("[data-dismiss-notification]").forEach((button) => { + button.addEventListener("click", async () => { + await api(`/admin/notifications/${button.dataset.dismissNotification}/dismiss`, { method: "POST" }); + await loadNotifications(); + }); + }); + } catch (error) { + renderError(root, error); + } + } + + async function loadUsers(search = "") { + const query = new URLSearchParams(); + if (search) query.set("search", search); + const data = await api(`/admin/users?${query.toString()}`); + renderTable(qs("#usersTable"), data.rows, ["id", "telegram_id", "username", "first_name", "platform_role", "created_at"]); + } + + async function loadSto(filters = {}) { + const query = new URLSearchParams(); + Object.entries(filters).forEach(([key, value]) => { + if (value) query.set(key, value); + }); + const data = await api(`/admin/sto?${query.toString()}`); + renderTable(qs("#stoTable"), data.rows, ["id", "display_name", "city", "phone", "verification_status", "owner_user_id", "created_at"]); + } + + async function loadApplications() { + const root = qs("#applicationsList"); + try { + const data = await api("/admin/sto-applications"); + if (!data.rows.length) return renderEmpty(root, "Очередь модерации пуста"); + root.innerHTML = data.rows + .map( + (item) => ` +
+
+ ${escapeHtml(item.display_name || item.legal_name || `СТО #${item.id}`)} + ${badge(item.verification_status)} ${escapeHtml(item.city || "-")} ${formatDateTime(item.created_at)} +
+
+ + + +
+
+ `, + ) + .join(""); + root.querySelectorAll("[data-application-action]").forEach((button) => { + button.addEventListener("click", async () => { + const action = button.dataset.applicationAction; + const reason = action === "approve" ? "Approved in admin panel" : window.prompt("Причина") || ""; + if (action !== "approve" && !reason) return; + await api(`/admin/sto-applications/${button.dataset.applicationId}/${action}`, { + method: "POST", + body: JSON.stringify({ reason, comment: reason }), + }); + toast("Статус заявки обновлен"); + await loadApplications(); + }); + }); + } catch (error) { + renderError(root, error); + } + } + + async function loadSourceTable(source, rootSelector, columns) { + const root = qs(rootSelector); + try { + const data = await api("/admin/data/query", { + method: "POST", + body: JSON.stringify({ source, limit: 100 }), + }); + renderTable(root, data.rows, columns); + } catch (error) { + renderError(root, error); + } + } + + function cleanPayload(payload) { + const cleaned = {}; + Object.entries(payload).forEach(([key, value]) => { + if (value === "" || value === null || value === undefined) return; + if (["user_id", "telegram_id", "vehicle_id", "sto_id", "limit"].includes(key)) { + cleaned[key] = Number(value); + } else if (key === "include_sensitive") { + cleaned[key] = value === "on"; + } else { + cleaned[key] = value; + } + }); + if (!("include_sensitive" in cleaned)) cleaned.include_sensitive = false; + return cleaned; + } + + async function submitDataQuery(format = null) { + const payload = cleanPayload(formData(qs("#dataForm"))); + state.lastDataPayload = payload; + if (format) { + const result = await api("/admin/data/export", { + method: "POST", + body: JSON.stringify({ ...payload, export_format: format }), + }); + toast(`Export #${result.id} готов`); + await loadExports(); + return; + } + const data = await api("/admin/data/query", { + method: "POST", + body: JSON.stringify(payload), + }); + renderTable(qs("#dataResult"), data.rows); + } + + async function loadAudit(params = {}) { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value) query.set(key, value); + }); + const rows = await api(`/admin/audit-log?${query.toString()}`); + renderTable(qs("#auditTable"), rows, ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "created_at"]); + } + + async function loadExports() { + const data = await api("/admin/exports"); + renderTable(qs("#exportsTable"), data.rows, ["id", "source", "export_format", "status", "row_count", "reason", "expires_at", "created_at"]); + } + + async function loadActiveSection() { + if (state.active === "dashboard") return loadDashboard(); + if (state.active === "notifications") return loadNotifications(); + if (state.active === "users") return loadUsers(); + if (state.active === "sto") return loadSto(); + if (state.active === "sto-applications") return loadApplications(); + if (state.active === "vehicles") return loadSourceTable("vehicles", "#vehiclesTable", ["id", "owner_id", "name", "make", "model", "year", "vin", "plate_number", "current_odometer", "created_at"]); + if (state.active === "appointments") return loadSourceTable("appointments", "#appointmentsTable", ["id", "service_center_id", "vehicle_id", "owner_user_id", "service_type", "status", "requested_start_at", "created_at"]); + if (state.active === "work-orders") return loadSourceTable("work_orders", "#workOrdersTable", ["id", "service_center_id", "vehicle_id", "owner_user_id", "status", "final_total", "currency", "completed_at"]); + if (state.active === "audit") return loadAudit(); + if (state.active === "exports") return loadExports(); + return null; + } + + function bindTabButtons() { + document.querySelectorAll("[data-admin-tab]").forEach((button) => { + button.addEventListener("click", async () => { + setActive(button.dataset.adminTab); + try { + await loadActiveSection(); + } catch (error) { + toast(error.message || "Ошибка", "error"); + } + }); + }); + } + + function bindForms() { + qs("#refreshBtn")?.addEventListener("click", () => loadActiveSection().catch((error) => toast(error.message, "error"))); + qs("#readAllBtn")?.addEventListener("click", async () => { + await api("/admin/notifications/read-all", { method: "POST" }); + await loadNotifications(); + }); + document.querySelector("[data-list-filter='users']")?.addEventListener("submit", async (event) => { + event.preventDefault(); + await loadUsers(formData(event.currentTarget).search || ""); + }); + document.querySelector("[data-list-filter='sto']")?.addEventListener("submit", async (event) => { + event.preventDefault(); + await loadSto(formData(event.currentTarget)); + }); + qs("#dataForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + await submitDataQuery().catch((error) => toast(error.message, "error")); + }); + qs("#exportJsonBtn")?.addEventListener("click", () => submitDataQuery("json").catch((error) => toast(error.message, "error"))); + qs("#exportCsvBtn")?.addEventListener("click", () => submitDataQuery("csv").catch((error) => toast(error.message, "error"))); + qs("#auditForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + await loadAudit(cleanPayload(formData(event.currentTarget))); + }); + } + + async function initSources() { + const data = await api("/admin/data/sources"); + state.sources = data.sources || []; + state.sorts = data.sorts || []; + qs("#sourceSelect").innerHTML = state.sources + .filter((source) => source.available && source.allowed) + .map((source) => ``) + .join(""); + qs("#sortSelect").innerHTML = state.sorts + .map((sort) => ``) + .join(""); + } + + async function init() { + qs("#adminRoleBadge").textContent = CarPassPage.state.user?.platform_role || "admin"; + qs("#adminMeta").textContent = `User #${CarPassPage.state.user?.id || "-"} · Telegram ${CarPassPage.state.user?.telegram_id || "-"}`; + await initSources(); + bindTabButtons(); + bindForms(); + const urlSection = new URLSearchParams(window.location.search).get("section"); + setActive(urlSection || "dashboard"); + await loadActiveSection(); + } + + return { init }; +})(); + +CarPassPage.boot(AdminPage.init); diff --git a/web/static/styles.css b/web/static/styles.css index 009e17d..c5ec287 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -2069,6 +2069,180 @@ select { font-size: 12px; } +.admin-page { + background: + linear-gradient(180deg, #ffffff 0, #edf5f2 220px), + var(--bg); +} + +.admin-shell { + width: min(1320px, 100%); +} + +.admin-hero { + margin-bottom: 10px; +} + +.admin-tabs { + position: sticky; + top: 0; + z-index: 8; + display: flex; + gap: 8px; + padding: 8px 0 12px; + margin-bottom: 6px; + overflow-x: auto; + background: rgba(238, 243, 241, 0.92); + backdrop-filter: blur(10px); +} + +.admin-tabs button, +.admin-link-grid button { + flex: 0 0 auto; + min-height: 38px; + padding: 0 12px; + border: 1px solid var(--line); + background: #fff; + color: var(--text); + box-shadow: none; +} + +.admin-tabs button.active { + border-color: rgba(22, 128, 106, 0.48); + background: #dff1eb; + color: #0e5d4b; +} + +.admin-panel { + margin-bottom: 16px; +} + +.admin-stats { + grid-template-columns: repeat(6, minmax(130px, 1fr)); +} + +.admin-grid { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr); + gap: 14px; +} + +.admin-grid h3 { + margin: 0 0 8px; + font-size: 15px; +} + +.admin-link-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.admin-filter { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; + align-items: end; + margin-bottom: 12px; +} + +.admin-filter button { + min-width: 112px; +} + +.admin-table-wrap { + width: 100%; + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; +} + +.admin-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + font-size: 13px; +} + +.admin-table th, +.admin-table td { + padding: 10px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; +} + +.admin-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f5faf7; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.admin-table tr:last-child td { + border-bottom: 0; +} + +.admin-badge { + display: inline-flex; + min-height: 22px; + align-items: center; + padding: 2px 7px; + border: 1px solid rgba(22, 128, 106, 0.18); + border-radius: 8px; + background: #eef7f3; + color: #0e5d4b; + font-size: 12px; + font-weight: 700; +} + +.admin-data-form { + align-items: end; + margin-bottom: 12px; +} + +.admin-check { + min-height: 42px; + align-content: center; +} + +.admin-form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.error-state { + color: var(--danger); + background: #fff4f2; +} + +@media (max-width: 980px) { + .admin-stats { + display: flex; + overflow-x: auto; + padding-bottom: 2px; + } + + .admin-stats .stat { + min-width: 150px; + } + + .admin-grid, + .admin-link-grid { + grid-template-columns: 1fr; + } + + .admin-tabs { + top: 0; + } +} + .work-order-total strong { color: #fff; font-size: clamp(24px, 4vw, 34px); -- 2.49.1 From 2d5695fdce5a873c2740dbef8ffc9ca2cb55247b Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Sun, 17 May 2026 21:16:30 +0900 Subject: [PATCH 3/6] cover admin notifications and data explorer --- tests/conftest.py | 2 + tests/test_admin_control_center.py | 249 +++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 tests/test_admin_control_center.py diff --git a/tests/conftest.py b/tests/conftest.py index c6a7235..aed9679 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,8 @@ def configure_settings() -> None: settings.internal_api_token = TEST_INTERNAL_TOKEN settings.app_env = "test" settings.allow_dev_auth = False + settings.admin_telegram_ids = "" + settings.admin_notification_chat_id = "" yield diff --git a/tests/test_admin_control_center.py b/tests/test_admin_control_center.py new file mode 100644 index 0000000..467c0ec --- /dev/null +++ b/tests/test_admin_control_center.py @@ -0,0 +1,249 @@ +import pytest +from conftest import make_init_data + +from app.core.config import settings +from app.services import admin_notifications + + +async def ensure_admin(client, internal_headers) -> None: + await client.post( + "/api/users", + headers=internal_headers, + json={"telegram_id": 9001, "first_name": "Admin", "platform_role": "admin"}, + ) + + +async def ensure_analyst(client, internal_headers) -> dict[str, str]: + await client.post( + "/api/users", + headers=internal_headers, + json={"telegram_id": 7001, "first_name": "Analyst", "platform_role": "analyst"}, + ) + return {"X-Telegram-Init-Data": make_init_data(7001, "Analyst")} + + +@pytest.mark.asyncio +async def test_new_user_creates_admin_notification(client, admin_auth_headers, internal_headers) -> None: + await ensure_admin(client, internal_headers) + response = await client.post( + "/api/users", + headers=internal_headers, + json={"telegram_id": 123456, "first_name": "Ivan", "username": "ivan"}, + ) + + notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers) + + assert response.status_code == 200 + assert any( + item["event_type"] == "user_registered" and item["idempotency_key"] == "user_registered:123456" + for item in notifications.json()["rows"] + ) + + +@pytest.mark.asyncio +async def test_admin_notification_idempotency_for_user_registration( + client, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + payload = {"telegram_id": 223344, "first_name": "Repeat"} + + await client.post("/api/users", headers=internal_headers, json=payload) + await client.post("/api/users", headers=internal_headers, json=payload) + notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers) + + matches = [ + item + for item in notifications.json()["rows"] + if item["idempotency_key"] == "user_registered:223344" + ] + assert len(matches) == 1 + + +@pytest.mark.asyncio +async def test_telegram_admin_delivery_failure_does_not_break_user_flow( + client, admin_auth_headers, internal_headers, monkeypatch +) -> None: + class BrokenTelegramClient: + def __init__(self, *args, **kwargs): # noqa: D107 + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def post(self, *args, **kwargs): # noqa: ARG002 + raise RuntimeError("telegram down") + + monkeypatch.setattr(settings, "admin_telegram_ids", "777") + monkeypatch.setattr(admin_notifications.httpx, "AsyncClient", BrokenTelegramClient) + + response = await client.post( + "/api/users", + headers=internal_headers, + json={"telegram_id": 334455, "first_name": "Best Effort"}, + ) + await ensure_admin(client, internal_headers) + notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers) + + assert response.status_code == 200 + created = [ + item + for item in notifications.json()["rows"] + if item["idempotency_key"] == "user_registered:334455" + ][0] + assert created["telegram_status"] == "failed" + + +@pytest.mark.asyncio +async def test_new_sto_application_creates_admin_notification( + client, auth_headers, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + + center = await client.post( + "/api/service-centers", + headers=auth_headers, + json={ + "display_name": "Auto Master", + "country": "KR", + "city": "Gwangju", + "phone": "+82-10-0000-0000", + "document_photo_urls": ["doc-a.jpg", "doc-b.jpg"], + }, + ) + notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers) + + assert center.status_code == 201 + assert any( + item["event_type"] == "sto_application_created" + and item["entity_id"] == str(center.json()["id"]) + for item in notifications.json()["rows"] + ) + + +@pytest.mark.asyncio +async def test_admin_dashboard_requires_admin_role(client, auth_headers, admin_auth_headers, internal_headers) -> None: + forbidden = await client.get("/api/admin/dashboard", headers=auth_headers) + await ensure_admin(client, internal_headers) + allowed = await client.get("/api/admin/dashboard", headers=admin_auth_headers) + + assert forbidden.status_code == 403 + assert allowed.status_code == 200 + assert "users_total" in allowed.json() + + +@pytest.mark.asyncio +async def test_data_explorer_rejects_unknown_source_and_field( + client, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + + unknown_source = await client.post( + "/api/admin/data/query", + headers=admin_auth_headers, + json={"source": "raw_sql", "limit": 25}, + ) + forbidden_field = await client.post( + "/api/admin/data/query", + headers=admin_auth_headers, + json={"source": "users", "limit": 25, "sql": "select * from users"}, + ) + + assert unknown_source.status_code == 400 + assert forbidden_field.status_code == 422 + + +@pytest.mark.asyncio +async def test_data_explorer_masks_sensitive_data_and_applies_limit( + client, internal_headers +) -> None: + analyst_headers = await ensure_analyst(client, internal_headers) + await client.post( + "/api/users", + headers=internal_headers, + json={"telegram_id": 889900, "first_name": "Visible", "platform_role": "user"}, + ) + + response = await client.post( + "/api/admin/data/query", + headers=analyst_headers, + json={"source": "users", "limit": 1}, + ) + + assert response.status_code == 200 + rows = response.json()["rows"] + assert len(rows) == 1 + assert isinstance(rows[0]["telegram_id"], str) + assert rows[0]["telegram_id"] != "889900" + + +@pytest.mark.asyncio +async def test_sensitive_data_requires_admin_and_reason( + client, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + + missing_reason = await client.post( + "/api/admin/data/query", + headers=admin_auth_headers, + json={"source": "users", "include_sensitive": True, "limit": 25}, + ) + with_reason = await client.post( + "/api/admin/data/query", + headers=admin_auth_headers, + json={ + "source": "users", + "include_sensitive": True, + "reason": "support request", + "telegram_id": 9001, + "limit": 25, + }, + ) + + assert missing_reason.status_code == 400 + assert with_reason.status_code == 200 + assert with_reason.json()["rows"][0]["telegram_id"] == 9001 + + +@pytest.mark.asyncio +async def test_data_query_creates_audit_log(client, admin_auth_headers, internal_headers) -> None: + await ensure_admin(client, internal_headers) + + await client.post( + "/api/admin/data/query", + headers=admin_auth_headers, + json={"source": "users", "limit": 25}, + ) + audit = await client.get("/api/admin/audit-log?action=admin.data.query", headers=admin_auth_headers) + + assert audit.status_code == 200 + assert any(item["action"] == "admin.data.query" for item in audit.json()) + + +@pytest.mark.asyncio +async def test_pending_sto_queue_and_approve_audit( + client, auth_headers, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + center = ( + await client.post( + "/api/service-centers", + headers=auth_headers, + json={"display_name": "Pending Admin Queue", "country": "KR", "city": "Seoul"}, + ) + ).json() + + pending = await client.get("/api/admin/sto-applications", headers=admin_auth_headers) + approved = await client.post( + f"/api/admin/sto-applications/{center['id']}/approve", + headers=admin_auth_headers, + json={"comment": "ok"}, + ) + audit = await client.get("/api/admin/audit-log?action=service_center.verify", headers=admin_auth_headers) + + assert center["id"] in [item["id"] for item in pending.json()["rows"]] + assert approved.status_code == 200 + assert approved.json()["verification_status"] == "approved" + assert any(item["action"] == "service_center.verify" for item in audit.json()) -- 2.49.1 From 22b9b40d787110a4888fc4b5fab672da07e10bbe Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Mon, 18 May 2026 18:17:53 +0900 Subject: [PATCH 4/6] harden deploy reports and admin alerts --- ADMIN.md | 16 ++++ app/api/admin.py | 32 ++++++- app/api/ocr.py | 142 +++++++++++++++++++++++++--- app/main.py | 26 ++++- app/services/admin_notifications.py | 16 ++++ app/services/rate_limit.py | 104 +++++++++++++++++--- scripts/rsync_deploy.sh | 96 +++++++++++++++++++ scripts/send_telegram_report.py | 81 ++++++++++++++++ scripts/smoke_test.sh | 6 ++ tests/test_admin_control_center.py | 51 ++++++++++ web/admin.html | 5 +- web/static/admin.js | 5 + 12 files changed, 549 insertions(+), 31 deletions(-) create mode 100755 scripts/rsync_deploy.sh create mode 100755 scripts/send_telegram_report.py diff --git a/ADMIN.md b/ADMIN.md index fc42566..b147b02 100644 --- a/ADMIN.md +++ b/ADMIN.md @@ -53,6 +53,7 @@ Notifications: - `GET /api/admin/notifications` - `POST /api/admin/notifications/{id}/read` - `POST /api/admin/notifications/read-all` +- `POST /api/admin/notifications/retry` - `POST /api/admin/notifications/{id}/dismiss` Data Explorer: @@ -153,3 +154,18 @@ Data Explorer работает только по whitelist источников - `/admin_alerts` API дополнительно проверяет роль пользователя, поэтому команда не дает доступа без admin-role в БД. + +## Deploy Reports + +Для временного rsync-деплоя есть `scripts/rsync_deploy.sh`. Скрипт: + +- запускает локальные `ruff` и `pytest`; +- отправляет Telegram progress/failure/success отчеты; +- делает remote code backup без `.env`; +- синхронизирует код через `rsync`; +- собирает Docker images; +- применяет Alembic migrations; +- поднимает `api` и `bot`; +- проверяет `/health`, `/ready`, `/metrics`, `/admin.html`, `/sto.html`, `/work_order.html`. + +Утилита `scripts/send_telegram_report.py` берет получателей из `ADMIN_NOTIFICATION_CHAT_ID`, `ADMIN_TELEGRAM_IDS` и, если env пустой, из пользователей БД с ролями `admin`, `super_admin`, `moderator`, `support`. diff --git a/app/api/admin.py b/app/api/admin.py index 96c696b..9f41e64 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -36,8 +36,9 @@ from app.services.admin_notifications import ( create_admin_notification, dismiss_admin_notification, mark_admin_notification_read, + retry_admin_telegram_notifications, ) -from app.services.notifications import notify_user +from app.services.notifications import notify_user, process_notification_queue router = APIRouter(prefix="/admin", tags=["admin"]) @@ -456,6 +457,35 @@ async def read_all_admin_notifications( return {"updated": len(rows)} +@router.post("/notifications/retry") +async def retry_notifications( + limit: int = 50, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> dict[str, Any]: + require_admin_access(current_user, FULL_ADMIN_ROLES | {"support"}) + limit = min(max(limit, 1), 200) + service_delivered = await process_notification_queue(session, limit=limit) + admin_delivered = await retry_admin_telegram_notifications(session, limit=limit) + await log_audit( + session, + actor=current_user, + action="admin.notifications.retry", + target_type="notifications", + metadata={ + "limit": limit, + "service_delivered": service_delivered, + "admin_delivered": admin_delivered, + }, + ) + await session.commit() + return { + "service_delivered": service_delivered, + "admin_delivered": admin_delivered, + "limit": limit, + } + + @router.post("/notifications/{notification_id}/dismiss") async def dismiss_notification( notification_id: int, diff --git a/app/api/ocr.py b/app/api/ocr.py index 530012d..16cd1b1 100644 --- a/app/api/ocr.py +++ b/app/api/ocr.py @@ -1,14 +1,16 @@ import re +import time from datetime import date from decimal import Decimal -from fastapi import APIRouter, Depends, File, Request, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_telegram_user from app.db.session import get_session from app.models.user import User +from app.services.admin_notifications import create_admin_notification from app.services.ocr_provider import get_ocr_provider from app.services.rate_limit import check_rate_limit from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload @@ -40,6 +42,72 @@ class OCRResultRead(BaseModel): provider: str = "heuristic" +async def validate_ocr_upload( + *, + session: AsyncSession, + current_user: User, + content: bytes, + filename: str | None, + content_type: str | None, +) -> str: + try: + return validate_upload( + content=content, + filename=filename, + content_type=content_type, + max_bytes=MAX_OCR_FILE_BYTES, + allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES, + ) + except HTTPException as exc: + await create_admin_notification( + session, + event_type="upload_blocked", + title="Upload blocked", + body=f"OCR upload blocked: {filename or '-'}\nReason: {exc.detail}", + entity_type="user", + entity_id=current_user.id, + severity="warning", + idempotency_key=( + f"upload_blocked:{current_user.id}:{filename or 'upload'}:{exc.status_code}:" + f"{int(time.time() // 60)}" + ), + metadata={ + "filename": filename, + "content_type": content_type, + "status_code": exc.status_code, + "detail": exc.detail, + }, + ) + await session.commit() + raise + + +async def recognize_with_alert( + *, + session: AsyncSession, + current_user: User, + content: bytes, + filename: str | None, + scope: str, +): + try: + return await get_ocr_provider().recognize(content, filename) + except Exception as exc: # noqa: BLE001 - OCR must fail gracefully and alert admins + await create_admin_notification( + session, + event_type="ocr_failed", + title="OCR provider failed", + body=f"Scope: {scope}\nFile: {filename or '-'}\nError: {type(exc).__name__}", + entity_type="user", + entity_id=current_user.id, + severity="error", + idempotency_key=f"ocr_failed:{scope}:{current_user.id}:{int(time.time() // 60)}", + metadata={"scope": scope, "filename": filename, "error_type": type(exc).__name__}, + ) + await session.commit() + return None + + @router.post("/parse-text-receipt", response_model=ReceiptSuggestion) async def parse_text_receipt( request: Request, @@ -49,17 +117,23 @@ async def parse_text_receipt( ) -> ReceiptSuggestion: await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session) content = await file.read() - validate_upload( - content=content, + await validate_ocr_upload( + session=session, + current_user=current_user, filename=file.filename, content_type=file.content_type, - max_bytes=MAX_OCR_FILE_BYTES, - allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES, + content=content, ) content_type = (file.content_type or "").lower() if content_type.startswith("image/") or content_type == "application/pdf": - result = await get_ocr_provider().recognize(content, file.filename) - if not result.recognized_text: + result = await recognize_with_alert( + session=session, + current_user=current_user, + content=content, + filename=file.filename, + scope="parse_text_receipt", + ) + if not result or not result.recognized_text: return ReceiptSuggestion( confidence=0, message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.", @@ -133,8 +207,22 @@ async def recognize_license_plate( ) -> OCRResultRead: await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session) content = await file.read() - validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES) - result = await get_ocr_provider().recognize(content, file.filename) + await validate_ocr_upload( + session=session, + current_user=current_user, + content=content, + filename=file.filename, + content_type=file.content_type, + ) + result = await recognize_with_alert( + session=session, + current_user=current_user, + content=content, + filename=file.filename, + scope="license_plate", + ) + if result is None: + return OCRResultRead(recognized_text="", candidates=[], provider="error") return OCRResultRead( recognized_text=result.recognized_text, candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"], @@ -151,8 +239,22 @@ async def recognize_vin( ) -> OCRResultRead: await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session) content = await file.read() - validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES) - result = await get_ocr_provider().recognize(content, file.filename) + await validate_ocr_upload( + session=session, + current_user=current_user, + content=content, + filename=file.filename, + content_type=file.content_type, + ) + result = await recognize_with_alert( + session=session, + current_user=current_user, + content=content, + filename=file.filename, + scope="vin", + ) + if result is None: + return OCRResultRead(recognized_text="", candidates=[], provider="error") return OCRResultRead( recognized_text=result.recognized_text, candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"], @@ -169,8 +271,22 @@ async def recognize_service_document( ) -> OCRResultRead: await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session) content = await file.read() - validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES) - result = await get_ocr_provider().recognize(content, file.filename) + await validate_ocr_upload( + session=session, + current_user=current_user, + content=content, + filename=file.filename, + content_type=file.content_type, + ) + result = await recognize_with_alert( + session=session, + current_user=current_user, + content=content, + filename=file.filename, + scope="service_document", + ) + if result is None: + return OCRResultRead(recognized_text="", candidates=[], provider="error") return OCRResultRead( recognized_text=result.recognized_text, candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates], diff --git a/app/main.py b/app/main.py index bacb239..ce6e00b 100644 --- a/app/main.py +++ b/app/main.py @@ -25,7 +25,8 @@ from app.api import ( work_orders, ) from app.core.config import settings -from app.db.session import get_session +from app.db.session import async_session_factory, get_session +from app.services.admin_notifications import create_admin_notification from app.services.rate_limit import get_redis_client @@ -49,8 +50,29 @@ async def production_headers_and_metrics(request: Request, call_next): start = monotonic() try: response = await call_next(request) - except Exception: + except Exception as exc: REQUEST_ERRORS += 1 + try: + async with async_session_factory() as session: + await create_admin_notification( + session, + event_type="system_error", + title="Unhandled API error", + body=f"{request.method} {request.url.path}\nError: {type(exc).__name__}", + entity_type="system", + entity_id=request.url.path, + severity="error", + idempotency_key=f"system_error:{request.url.path}:{type(exc).__name__}:{int(start // 60)}", + metadata={ + "path": request.url.path, + "method": request.method, + "request_id": request_id, + "error_type": type(exc).__name__, + }, + ) + await session.commit() + except Exception: + pass raise duration = monotonic() - start REQUEST_COUNT += 1 diff --git a/app/services/admin_notifications.py b/app/services/admin_notifications.py index 35e6d94..9c2b4f5 100644 --- a/app/services/admin_notifications.py +++ b/app/services/admin_notifications.py @@ -132,6 +132,22 @@ async def send_admin_telegram_notification(notification: AdminNotification) -> N notification.telegram_status = "sent" +async def retry_admin_telegram_notifications(session: AsyncSession, *, limit: int = 50) -> int: + result = await session.execute( + select(AdminNotification) + .where(AdminNotification.telegram_status.in_(["pending", "failed"])) + .order_by(AdminNotification.created_at.asc()) + .limit(limit) + ) + delivered = 0 + for notification in result.scalars(): + await send_admin_telegram_notification(notification) + if notification.telegram_status == "sent": + delivered += 1 + await session.commit() + return delivered + + async def mark_admin_notification_read( session: AsyncSession, notification: AdminNotification ) -> AdminNotification: diff --git a/app/services/rate_limit.py b/app/services/rate_limit.py index eb02fec..83ca023 100644 --- a/app/services/rate_limit.py +++ b/app/services/rate_limit.py @@ -41,7 +41,13 @@ async def check_rate_limit( if settings.redis_url: allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds) if not allowed: - await log_rate_limit_event(session, scope=scope, identifier="redis") + await log_rate_limit_event( + session, + scope=scope, + identifier="redis", + user=user, + request=request, + ) raise_rate_limit(scope, window_seconds) return @@ -52,7 +58,13 @@ async def check_rate_limit( while bucket and now - bucket[0] > window_seconds: bucket.popleft() if len(bucket) >= limit: - await log_rate_limit_event(session, scope=scope, identifier=str(identifier)) + await log_rate_limit_event( + session, + scope=scope, + identifier=str(identifier), + user=user, + request=request, + ) raise_rate_limit(scope, window_seconds) for identifier in identifiers: _buckets[(scope, identifier)].append(now) @@ -107,18 +119,82 @@ async def log_rate_limit_event( *, scope: str, identifier: str, + user: User | None = None, + request: Request | None = None, ) -> None: - if session is None: - return - from app.models.car import AuditLog + client_host = request.client.host if request and request.client else None + user_agent = request.headers.get("user-agent") if request else None + metadata = { + "scope": scope, + "identifier": identifier, + "telegram_id": user.telegram_id if user else None, + "user_id": user.id if user else None, + "ip": client_host, + } - session.add( - AuditLog( - actor_user_id=None, - actor_role="system", - action="rate_limit.exceeded", - target_type=scope, - target_id=identifier[:80], - metadata_json={"scope": scope, "identifier": identifier}, - ) + if session is None: + from app.db.session import async_session_factory + + async with async_session_factory() as event_session: + await persist_rate_limit_event( + event_session, + scope=scope, + identifier=identifier, + user=user, + client_host=client_host, + user_agent=user_agent, + metadata=metadata, + ) + return + + await persist_rate_limit_event( + session, + scope=scope, + identifier=identifier, + user=user, + client_host=client_host, + user_agent=user_agent, + metadata=metadata, ) + + +async def persist_rate_limit_event( + event_session: AsyncSession, + *, + scope: str, + identifier: str, + user: User | None, + client_host: str | None, + user_agent: str | None, + metadata: dict, +) -> None: + from app.models.car import AuditLog + from app.services.admin_notifications import create_admin_notification + + try: + event_session.add( + AuditLog( + actor_user_id=user.id if user else None, + actor_role=user.platform_role if user else "system", + action="rate_limit.exceeded", + target_type=scope, + target_id=identifier[:80], + metadata_json=metadata, + ip=client_host, + user_agent=user_agent[:256] if user_agent else None, + ) + ) + await create_admin_notification( + event_session, + event_type="rate_limit_exceeded", + title="Rate limit exceeded", + body=f"Scope: {scope}\nIdentifier: {identifier}", + entity_type="user" if user else "system", + entity_id=user.id if user else scope, + severity="warning", + idempotency_key=f"rate_limit:{scope}:{identifier}:{int(time.time() // max(60, 1))}", + metadata=metadata, + ) + await event_session.commit() + except Exception: + await event_session.rollback() diff --git a/scripts/rsync_deploy.sh b/scripts/rsync_deploy.sh new file mode 100755 index 0000000..2155ebd --- /dev/null +++ b/scripts/rsync_deploy.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +REMOTE="${REMOTE:-root@drivers.smartsoltech.kr}" +REMOTE_DIR="${REMOTE_DIR:-/opt/drivers_bot}" +BASE_URL="${BASE_URL:-http://127.0.0.1:8000}" +COMPOSE="${COMPOSE:-docker compose}" +RUN_LOCAL_CHECKS="${RUN_LOCAL_CHECKS:-true}" +BACKUP_BEFORE_DEPLOY="${BACKUP_BEFORE_DEPLOY:-true}" +BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)" +REVISION="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" + +EXCLUDES=( + "--exclude=.git/" + "--exclude=.env" + "--exclude=.env.*" + "--exclude=.venv/" + "--exclude=venv/" + "--exclude=__pycache__/" + "--exclude=.pytest_cache/" + "--exclude=.ruff_cache/" + "--exclude=.history/" + "--exclude=backups/" + "--exclude=*.sqlite" + "--exclude=*.sqlite3" + "--exclude=*.db" +) + +send_remote_report() { + local text="$1" + ssh "$REMOTE" "cd '$REMOTE_DIR' && CARPASS_REPORT_TEXT=\$(cat) $COMPOSE exec -T -e CARPASS_REPORT_TEXT api python scripts/send_telegram_report.py" <<<"$text" || true +} + +fail_report() { + local line="${1:-unknown}" + send_remote_report "❌ CarPass rsync deploy failed +Branch: $BRANCH +Revision: $REVISION +Step line: $line +Target: $REMOTE" +} + +trap 'fail_report "$LINENO"' ERR + +if [[ "$RUN_LOCAL_CHECKS" == "true" ]]; then + echo "Running local checks..." + .venv/bin/ruff check app bot tests + .venv/bin/pytest -q +fi + +send_remote_report "🚀 CarPass rsync deploy started +Branch: $BRANCH +Revision: $REVISION +Target: $REMOTE +Checks: local=${RUN_LOCAL_CHECKS}" + +echo "Checking remote..." +ssh "$REMOTE" "test -d '$REMOTE_DIR' && test -f '$REMOTE_DIR/docker-compose.yml'" + +if [[ "$BACKUP_BEFORE_DEPLOY" == "true" ]]; then + echo "Creating remote code backup..." + ssh "$REMOTE" "cd '$(dirname "$REMOTE_DIR")' && mkdir -p '$REMOTE_DIR/backups' && tar --exclude='$(basename "$REMOTE_DIR")/backups' --exclude='$(basename "$REMOTE_DIR")/.git' --exclude='$(basename "$REMOTE_DIR")/.env' --exclude='$(basename "$REMOTE_DIR")/.venv' -czf '$REMOTE_DIR/backups/code_pre_rsync_$(date +%Y%m%d%H%M%S).tgz' '$(basename "$REMOTE_DIR")'" +fi + +echo "Syncing code with rsync..." +rsync -az --delete "${EXCLUDES[@]}" ./ "$REMOTE:$REMOTE_DIR/" + +echo "Building remote images..." +ssh "$REMOTE" "cd '$REMOTE_DIR' && $COMPOSE build" +send_remote_report "🧱 CarPass rsync deploy progress +Branch: $BRANCH +Step: docker build completed" + +echo "Applying migrations..." +ssh "$REMOTE" "cd '$REMOTE_DIR' && $COMPOSE run --rm api alembic upgrade head" +send_remote_report "🗄️ CarPass rsync deploy progress +Branch: $BRANCH +Step: migrations applied" + +echo "Starting services..." +ssh "$REMOTE" "cd '$REMOTE_DIR' && $COMPOSE up -d" + +echo "Waiting for API readiness..." +ssh "$REMOTE" "cd '$REMOTE_DIR' && for i in \$(seq 1 30); do status=\$(docker inspect -f '{{.State.Health.Status}}' drivers_bot-api-1 2>/dev/null || echo missing); echo \"api_health=\$status\"; [ \"\$status\" = healthy ] && exit 0; sleep 2; done; $COMPOSE logs --tail=120 api; exit 1" + +echo "Running remote smoke tests..." +ssh "$REMOTE" "cd '$REMOTE_DIR' && BASE_URL='$BASE_URL' ./scripts/smoke_test.sh && curl -fsSI '$BASE_URL/admin.html' | head -5 && $COMPOSE ps" + +send_remote_report "✅ CarPass rsync deploy completed +Branch: $BRANCH +Revision: $REVISION +Migration: $(ssh "$REMOTE" "cd '$REMOTE_DIR' && curl -fsS '$BASE_URL/ready'" | tr '\n' ' ') +Checks: /health ok, /ready ok, /metrics ok, /admin.html 200 +Services: api healthy, bot restarted" + +echo "Deploy completed." diff --git a/scripts/send_telegram_report.py b/scripts/send_telegram_report.py new file mode 100755 index 0000000..e95af49 --- /dev/null +++ b/scripts/send_telegram_report.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import os +from collections.abc import Iterable + +import httpx +from sqlalchemy import select + +from app.core.config import settings +from app.db.session import async_session_factory +from app.models import car, expense, gamification, push # noqa: F401 +from app.models.user import User + +REPORT_ROLES = {"admin", "super_admin", "moderator", "support"} + + +def env_recipients() -> list[str]: + recipients: list[str] = [] + if settings.admin_notification_chat_id: + recipients.append(settings.admin_notification_chat_id) + recipients.extend(str(item) for item in settings.admin_telegram_id_list) + return recipients + + +async def db_recipients() -> list[str]: + async with async_session_factory() as session: + result = await session.execute( + select(User.telegram_id).where(User.platform_role.in_(REPORT_ROLES)) + ) + return [str(row[0]) for row in result.all() if row[0]] + + +def unique(values: Iterable[str]) -> list[str]: + return list(dict.fromkeys(item.strip() for item in values if item and item.strip())) + + +async def send_report(text: str, *, dry_run: bool = False) -> int: + recipients = unique([*env_recipients(), *(await db_recipients())]) + if dry_run: + print(f"telegram_report_dry_run recipients={len(recipients)}") + return len(recipients) + if not settings.bot_token or not recipients: + print("telegram_report_skipped") + return 0 + + sent = 0 + async with httpx.AsyncClient(timeout=10) as client: + for chat_id in recipients: + try: + response = await client.post( + f"https://api.telegram.org/bot{settings.bot_token}/sendMessage", + json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, + ) + response.raise_for_status() + sent += 1 + except Exception as exc: # noqa: BLE001 - deploy report must never fail deploy + print(f"telegram_report_failed chat_id={chat_id} error={type(exc).__name__}") + print(f"telegram_report_sent_count {sent}") + return sent + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Send a CarPass operational Telegram report.") + parser.add_argument("--text", help="Report text. Defaults to CARPASS_REPORT_TEXT.") + parser.add_argument("--dry-run", action="store_true") + return parser.parse_args() + + +async def main() -> None: + args = parse_args() + text = args.text or os.getenv("CARPASS_REPORT_TEXT") or "" + if not text.strip(): + raise SystemExit("Report text is required") + await send_report(text, dry_run=args.dry_run) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh index a72517b..b40893d 100755 --- a/scripts/smoke_test.sh +++ b/scripts/smoke_test.sh @@ -13,4 +13,10 @@ echo echo "Checking metrics..." curl -fsS "$BASE_URL/metrics" | grep -q "carpass_requests_total" + +for path in / /sto.html /admin.html /work_order.html; do + echo "Checking static page $path..." + curl -fsSI "$BASE_URL$path" | grep -q "200 OK" +done + echo "Smoke test passed." diff --git a/tests/test_admin_control_center.py b/tests/test_admin_control_center.py index 467c0ec..fdcd514 100644 --- a/tests/test_admin_control_center.py +++ b/tests/test_admin_control_center.py @@ -247,3 +247,54 @@ async def test_pending_sto_queue_and_approve_audit( assert approved.status_code == 200 assert approved.json()["verification_status"] == "approved" assert any(item["action"] == "service_center.verify" for item in audit.json()) + + +@pytest.mark.asyncio +async def test_blocked_ocr_upload_creates_admin_notification( + client, auth_headers, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + + response = await client.post( + "/api/ocr/vin", + headers=auth_headers, + files={"file": ("invoice.exe", b"not an image", "image/jpeg")}, + ) + notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers) + + assert response.status_code == 415 + assert any(item["event_type"] == "upload_blocked" for item in notifications.json()["rows"]) + + +@pytest.mark.asyncio +async def test_rate_limit_creates_admin_notification( + client, auth_headers, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + + last_response = None + for index in range(9): + last_response = await client.post( + "/api/ocr/vin", + headers=auth_headers, + files={"file": (f"vin-{index}.txt", b"VIN KMHCT41BAHU123456", "text/plain")}, + ) + notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers) + + assert last_response is not None + assert last_response.status_code == 429 + assert any(item["event_type"] == "rate_limit_exceeded" for item in notifications.json()["rows"]) + + +@pytest.mark.asyncio +async def test_admin_can_retry_notification_queues( + client, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + + response = await client.post("/api/admin/notifications/retry", headers=admin_auth_headers) + audit = await client.get("/api/admin/audit-log?action=admin.notifications.retry", headers=admin_auth_headers) + + assert response.status_code == 200 + assert {"service_delivered", "admin_delivered", "limit"} <= response.json().keys() + assert any(item["action"] == "admin.notifications.retry" for item in audit.json()) diff --git a/web/admin.html b/web/admin.html index b7b8856..7b305fe 100644 --- a/web/admin.html +++ b/web/admin.html @@ -84,7 +84,10 @@

События

Admin notifications

- +
+ + +
diff --git a/web/static/admin.js b/web/static/admin.js index dac167d..2a8e8e2 100644 --- a/web/static/admin.js +++ b/web/static/admin.js @@ -321,6 +321,11 @@ const AdminPage = (() => { await api("/admin/notifications/read-all", { method: "POST" }); await loadNotifications(); }); + qs("#retryNotificationsBtn")?.addEventListener("click", async () => { + const result = await api("/admin/notifications/retry", { method: "POST" }); + toast(`Retry: service ${result.service_delivered}, admin ${result.admin_delivered}`); + await loadNotifications(); + }); document.querySelector("[data-list-filter='users']")?.addEventListener("submit", async (event) => { event.preventDefault(); await loadUsers(formData(event.currentTarget).search || ""); -- 2.49.1 From 59bc6ebd4f405cdfea43b96c84fa7f7ea56197c4 Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Mon, 18 May 2026 18:23:18 +0900 Subject: [PATCH 5/6] fix rsync deploy progress reports --- scripts/rsync_deploy.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/rsync_deploy.sh b/scripts/rsync_deploy.sh index 2155ebd..846dc8f 100755 --- a/scripts/rsync_deploy.sh +++ b/scripts/rsync_deploy.sh @@ -28,7 +28,7 @@ EXCLUDES=( send_remote_report() { local text="$1" - ssh "$REMOTE" "cd '$REMOTE_DIR' && CARPASS_REPORT_TEXT=\$(cat) $COMPOSE exec -T -e CARPASS_REPORT_TEXT api python scripts/send_telegram_report.py" <<<"$text" || true + ssh "$REMOTE" "cd '$REMOTE_DIR' && export CARPASS_REPORT_TEXT=\$(cat); if $COMPOSE exec -T api test -f scripts/send_telegram_report.py >/dev/null 2>&1; then $COMPOSE exec -T -e CARPASS_REPORT_TEXT api python scripts/send_telegram_report.py; else $COMPOSE run --rm --no-deps -e CARPASS_REPORT_TEXT api python scripts/send_telegram_report.py; fi" <<<"$text" || true } fail_report() { -- 2.49.1 From 8982299e712b5bd7632df56910a75ee7c2e4f3a3 Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Mon, 18 May 2026 18:37:19 +0900 Subject: [PATCH 6/6] add admin data mutations and load check --- ADMIN.md | 22 +++ app/api/admin.py | 215 +++++++++++++++++++++++++++++ bot/api_client.py | 78 ++++++----- bot/main.py | 6 +- scripts/load_check.py | 81 +++++++++++ tests/test_admin_control_center.py | 123 +++++++++++++++++ web/admin.html | 1 + web/static/admin.js | 119 ++++++++++++++-- web/static/styles.css | 49 ++++++- 9 files changed, 650 insertions(+), 44 deletions(-) create mode 100644 scripts/load_check.py diff --git a/ADMIN.md b/ADMIN.md index b147b02..1279123 100644 --- a/ADMIN.md +++ b/ADMIN.md @@ -60,6 +60,8 @@ Data Explorer: - `GET /api/admin/data/sources` - `POST /api/admin/data/query` +- `PATCH /api/admin/data/{source}/{id}` +- `DELETE /api/admin/data/{source}/{id}` - `POST /api/admin/data/export` Users: @@ -115,6 +117,16 @@ Data Explorer работает только по whitelist источников Поддержаны фильтры по дате, статусу, пользователю, Telegram ID, авто, СТО, городу, роли, категории, сумме, ошибкам и текстовому поиску. Каждый запрос ограничен `limit` до 500 строк и пишет audit log. +Редактирование и удаление из Data Explorer: + +- доступно только `admin` и `super_admin`; +- работает только по whitelist полей, который возвращает `GET /api/admin/data/sources`; +- требует `reason` минимум 5 символов; +- пишет `AuditLog` с old/new values; +- для пользователей, СТО, заявок, записей и уведомлений используется soft-delete/status change; +- hard-delete разрешен только для ограниченных журналов записей, где это явно включено; +- удаление автомобиля требует `super_admin`. + ## Privacy По умолчанию маскируются Telegram ID, VIN, госномер, телефон и регистрационные данные СТО. @@ -169,3 +181,13 @@ API дополнительно проверяет роль пользовате - проверяет `/health`, `/ready`, `/metrics`, `/admin.html`, `/sto.html`, `/work_order.html`. Утилита `scripts/send_telegram_report.py` берет получателей из `ADMIN_NOTIFICATION_CHAT_ID`, `ADMIN_TELEGRAM_IDS` и, если env пустой, из пользователей БД с ролями `admin`, `super_admin`, `moderator`, `support`. + +## Load Check + +Быстрая проверка одновременных соединений: + +```bash +python scripts/load_check.py --base-url http://127.0.0.1:8000 --requests 200 --concurrency 25 +``` + +Скрипт проверяет `/health`, `/ready`, `/`, `/admin.html`, `/sto.html`, считает RPS, avg/p95/max latency и завершится с ошибкой при 5xx или сетевых сбоях. diff --git a/app/api/admin.py b/app/api/admin.py index 9f41e64..250282d 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -1,4 +1,5 @@ import csv +import enum import io import json from datetime import UTC, date, datetime, timedelta @@ -83,6 +84,20 @@ class AdminUserNote(BaseModel): note: str +class AdminDataMutation(BaseModel): + model_config = ConfigDict(extra="forbid") + + values: dict[str, Any] = Field(default_factory=dict) + reason: str + + +class AdminDataDelete(BaseModel): + model_config = ConfigDict(extra="forbid") + + reason: str + hard: bool = False + + DATA_SOURCES: dict[str, dict[str, Any]] = { "users": { "model": User, @@ -91,6 +106,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = { "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"], + "editable": ["username", "first_name", "last_name", "platform_role", "locale", "currency"], + "delete": {"type": "soft", "field": "platform_role", "value": "blocked"}, }, "vehicles": { "model": Car, @@ -99,6 +116,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = { "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"], + "editable": ["name", "make", "model", "year", "vin", "plate_number", "license_plate_display", "current_odometer", "notes"], + "delete": {"type": "hard", "requires_super_admin": True}, }, "fuel_entries": { "model": FuelEntry, @@ -107,6 +126,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = { "amount": "total_cost", "date": "entry_date", "columns": ["id", "car_id", "entry_date", "odometer", "liters", "total_cost", "created_at"], + "editable": ["entry_date", "odometer", "liters", "price_per_liter", "total_cost", "station", "fuel_brand", "is_full_tank", "notes"], + "delete": {"type": "hard"}, }, "service_entries": { "model": ServiceEntry, @@ -116,6 +137,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = { "amount": "total_cost", "date": "entry_date", "columns": ["id", "car_id", "entry_date", "service_type", "title", "total_cost", "created_at"], + "editable": ["entry_date", "odometer", "service_type", "title", "category", "vendor", "total_cost", "next_due_date", "next_due_odometer", "notes"], + "delete": {"type": "hard"}, }, "expense_entries": { "model": ExpenseEntry, @@ -125,6 +148,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = { "amount": "total_cost", "date": "entry_date", "columns": ["id", "car_id", "entry_date", "category", "title", "total_cost", "currency", "created_at"], + "editable": ["entry_date", "category", "title", "vendor", "total_cost", "currency", "odometer", "period_start", "period_end", "period_months", "is_recurring", "notes"], + "delete": {"type": "hard"}, }, "sto_profiles": { "model": ServiceCenter, @@ -133,30 +158,40 @@ DATA_SOURCES: dict[str, dict[str, Any]] = { "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"], + "editable": ["display_name", "legal_name", "country", "city", "address", "phone", "contact_phone", "description", "working_hours", "contact_person", "verification_status"], + "delete": {"type": "soft", "field": "verification_status", "value": "suspended", "timestamp_field": "suspended_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"], + "editable": ["status", "comment", "reviewed_by", "reviewed_at"], + "delete": {"type": "soft", "field": "status", "value": "rejected"}, }, "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"], + "editable": ["role", "permissions", "status"], + "delete": {"type": "soft", "field": "status", "value": "revoked", "timestamp_field": "invite_revoked_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"], + "editable": ["access_level", "status", "external_vehicle_ref"], + "delete": {"type": "soft", "field": "status", "value": "revoked", "timestamp_field": "revoked_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"], + "editable": ["service_type", "service_name", "requested_start_at", "requested_end_at", "confirmed_start_at", "confirmed_end_at", "estimated_duration_minutes", "status", "customer_comment", "service_center_comment"], + "delete": {"type": "soft", "field": "status", "value": "cancelled"}, }, "work_orders": { "model": ServiceVisit, @@ -164,6 +199,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = { "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"], + "editable": ["visit_date", "odometer", "status", "customer_complaint", "diagnosis", "recommendations", "internal_notes", "labor_total", "product_total", "discount_total", "final_total", "currency"], + "delete": {"type": "soft", "field": "status", "value": "archived"}, }, "work_order_items": { "model": ServiceWorkItem, @@ -171,6 +208,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = { "filters": {"category": "category"}, "amount": "total", "columns": ["id", "service_visit_id", "work_type", "title", "category", "quantity", "total", "created_at"], + "editable": ["work_type", "title", "category", "description", "quantity", "unit", "unit_price", "discount", "total", "parts", "oil_brand", "oil_viscosity", "oil_volume"], + "delete": {"type": "hard"}, }, "work_order_products": { "model": ServiceProductItem, @@ -178,24 +217,32 @@ DATA_SOURCES: dict[str, dict[str, Any]] = { "filters": {"category": "category"}, "amount": "total", "columns": ["id", "service_visit_id", "title", "category", "product_type", "quantity", "total", "created_at"], + "editable": ["title", "category", "product_type", "brand", "sku", "quantity", "unit", "unit_price", "discount", "total"], + "delete": {"type": "hard"}, }, "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"], + "editable": ["rating", "text", "status", "service_response", "service_responded_at"], + "delete": {"type": "soft", "field": "status", "value": "hidden"}, }, "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"], + "editable": ["title", "body", "status", "retry_count", "last_error"], + "delete": {"type": "soft", "field": "status", "value": "dismissed"}, }, "admin_notifications": { "model": AdminNotification, "roles": ADMIN_ROLES, "filters": {"status": "status"}, "columns": ["id", "event_type", "severity", "title", "entity_type", "entity_id", "status", "created_at"], + "editable": ["severity", "title", "body", "status", "telegram_status", "telegram_error"], + "delete": {"type": "soft", "field": "status", "value": "dismissed", "timestamp_field": "dismissed_at"}, }, "audit_logs": { "model": AuditLog, @@ -210,6 +257,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = { "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"], + "editable": ["status", "reason", "expires_at"], + "delete": {"type": "soft", "field": "status", "value": "expired"}, }, } @@ -268,6 +317,79 @@ def source_config(source: str, user: User) -> dict[str, Any]: return config +def mutation_source_config(source: str, user: User, *, action: str) -> dict[str, Any]: + config = source_config(source, user) + if user.platform_role not in FULL_ADMIN_ROLES: + raise HTTPException(status_code=403, detail="Only admin roles can modify data") + if action == "update" and not config.get("editable"): + raise HTTPException(status_code=400, detail="Data source is read-only") + if action == "delete" and not config.get("delete"): + raise HTTPException(status_code=400, detail="Data source cannot be deleted from admin") + delete_config = config.get("delete") or {} + if action == "delete" and delete_config.get("requires_super_admin") and user.platform_role != "super_admin": + raise HTTPException(status_code=403, detail="Super admin role is required") + return config + + +def require_mutation_reason(reason: str) -> str: + clean = reason.strip() + if len(clean) < 5: + raise HTTPException(status_code=400, detail="reason is required") + return clean + + +def model_column_python_type(model: Any, field: str) -> type | None: + column = getattr(model, "__mapper__", None).columns.get(field) if getattr(model, "__mapper__", None) else None + if column is None: + return None + try: + return column.type.python_type + except NotImplementedError: + return None + + +def coerce_admin_value(model: Any, field: str, value: Any, current_value: Any) -> Any: + if value == "": + return None + if value is None: + return None + target_type = type(current_value) if current_value is not None else model_column_python_type(model, field) + if target_type is None: + return value + if isinstance(current_value, enum.Enum): + return current_value.__class__(value) + if isinstance(target_type, type) and issubclass(target_type, enum.Enum): + return target_type(value) + if target_type is Decimal: + return Decimal(str(value)) + if target_type is datetime: + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + if target_type is date: + if isinstance(value, date) and not isinstance(value, datetime): + return value + return date.fromisoformat(str(value)[:10]) + if target_type is bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on", "да"} + if target_type in {int, float, str}: + return target_type(value) + return value + + +async def get_mutation_item(session: AsyncSession, config: dict[str, Any], item_id: int) -> Any: + item = await session.get(config["model"], item_id) + if item is None: + raise HTTPException(status_code=404, detail="Record not found") + return item + + +def mutation_snapshot(item: Any, fields: list[str]) -> dict[str, Any]: + return {field: jsonable_encoder(getattr(item, field, None)) for field in fields} + + def apply_data_filters(stmt: Select, query: AdminDataQuery, config: dict[str, Any]) -> Select: model = config["model"] date_column = config.get("date", "created_at") @@ -513,6 +635,9 @@ async def admin_data_sources(current_user: User = Depends(get_current_telegram_u "columns": config.get("columns") or [], "sensitive": sorted(config.get("sensitive") or []), "allowed": current_user.platform_role in set(config["roles"]), + "editable": sorted(config.get("editable") or []), + "deletable": bool(config.get("delete")), + "delete_mode": (config.get("delete") or {}).get("type"), } for name, config in DATA_SOURCES.items() ], @@ -532,6 +657,96 @@ async def admin_data_query( return data +@router.patch("/data/{source}/{item_id}") +async def admin_data_update( + source: str, + item_id: int, + payload: AdminDataMutation, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> dict[str, Any]: + reason = require_mutation_reason(payload.reason) + config = mutation_source_config(source, current_user, action="update") + editable = set(config.get("editable") or []) + values = {key: value for key, value in payload.values.items() if key in editable} + if not payload.values: + raise HTTPException(status_code=400, detail="values are required") + forbidden = sorted(set(payload.values) - editable) + if forbidden: + raise HTTPException(status_code=400, detail=f"Forbidden fields: {', '.join(forbidden)}") + item = await get_mutation_item(session, config, item_id) + old_values = mutation_snapshot(item, sorted(values)) + for field, value in values.items(): + setattr(item, field, coerce_admin_value(config["model"], field, value, getattr(item, field, None))) + await session.flush() + await session.refresh(item) + new_values = mutation_snapshot(item, sorted(values)) + await log_audit( + session, + actor=current_user, + action="admin.data.update", + target_type=source, + target_id=item_id, + metadata={"reason": reason, "old": old_values, "new": new_values}, + ) + await session.commit() + await session.refresh(item) + return { + "source": source, + "id": item_id, + "row": serialize_row(item, source, include_sensitive=True, role=current_user.platform_role), + } + + +@router.delete("/data/{source}/{item_id}") +async def admin_data_delete( + source: str, + item_id: int, + payload: AdminDataDelete, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> dict[str, Any]: + reason = require_mutation_reason(payload.reason) + config = mutation_source_config(source, current_user, action="delete") + delete_config = config["delete"] + item = await get_mutation_item(session, config, item_id) + columns = public_columns(source) + old_values = mutation_snapshot(item, columns) + delete_type = delete_config.get("type") + if payload.hard and delete_type != "hard": + raise HTTPException(status_code=400, detail="Hard delete is not supported for this source") + if delete_type == "hard": + await session.delete(item) + result_payload: dict[str, Any] = {"source": source, "id": item_id, "deleted": True, "mode": "hard"} + else: + field = delete_config["field"] + if not hasattr(item, field): + raise HTTPException(status_code=400, detail="Soft delete field is not available") + setattr(item, field, delete_config.get("value")) + timestamp_field = delete_config.get("timestamp_field") + if timestamp_field and hasattr(item, timestamp_field): + setattr(item, timestamp_field, datetime.now(UTC)) + await session.flush() + await session.refresh(item) + result_payload = { + "source": source, + "id": item_id, + "deleted": True, + "mode": "soft", + "row": serialize_row(item, source, include_sensitive=True, role=current_user.platform_role), + } + await log_audit( + session, + actor=current_user, + action="admin.data.delete", + target_type=source, + target_id=item_id, + metadata={"reason": reason, "mode": result_payload["mode"], "old": old_values}, + ) + await session.commit() + return result_payload + + @router.post("/data/export") async def admin_data_export( payload: AdminExportRequest, diff --git a/bot/api_client.py b/bot/api_client.py index d0e7889..7ef9126 100644 --- a/bot/api_client.py +++ b/bot/api_client.py @@ -8,6 +8,21 @@ from app.core.config import settings class ApiClient: def __init__(self) -> None: self.base_url = settings.api_base_url.rstrip("/") + self._client: httpx.AsyncClient | None = None + + @property + def client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=httpx.Timeout(15.0, connect=5.0), + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + return self._client + + async def close(self) -> None: + if self._client is not None: + await self._client.aclose() def headers(self, telegram_id: int | None = None) -> dict[str, str]: headers = {"X-Internal-API-Token": settings.internal_api_token} @@ -24,18 +39,17 @@ class ApiClient: json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> Any: - async with httpx.AsyncClient(base_url=self.base_url, timeout=15) as client: - response = await client.request( - method, - path, - json=json, - params=params, - headers=self.headers(telegram_id), - ) - response.raise_for_status() - if response.status_code == 204: - return None - return response.json() + response = await self.client.request( + method, + path, + json=json, + params=params, + headers=self.headers(telegram_id), + ) + response.raise_for_status() + if response.status_code == 204: + return None + return response.json() async def upsert_user(self, telegram_user: Any) -> dict[str, Any]: payload = { @@ -44,34 +58,30 @@ class ApiClient: "first_name": telegram_user.first_name, "last_name": telegram_user.last_name, } - async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: - response = await client.post("/api/users", json=payload, headers=self.headers()) - response.raise_for_status() - return response.json() + response = await self.client.post("/api/users", json=payload, headers=self.headers()) + response.raise_for_status() + return response.json() async def list_cars(self, owner_id: int, telegram_id: int) -> list[dict[str, Any]]: - async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: - response = await client.get( - "/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id) - ) - response.raise_for_status() - return response.json() + response = await self.client.get( + "/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id) + ) + response.raise_for_status() + return response.json() async def create_car(self, owner_id: int, name: str, telegram_id: int) -> dict[str, Any]: - async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: - response = await client.post( - "/api/cars", - json={"owner_id": owner_id, "name": name}, - headers=self.headers(telegram_id), - ) - response.raise_for_status() - return response.json() + response = await self.client.post( + "/api/cars", + json={"owner_id": owner_id, "name": name}, + headers=self.headers(telegram_id), + ) + response.raise_for_status() + return response.json() async def stats(self, car_id: int, telegram_id: int) -> dict[str, Any]: - async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: - response = await client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id)) - response.raise_for_status() - return response.json() + response = await self.client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id)) + response.raise_for_status() + return response.json() async def create_fuel(self, telegram_id: int, payload: dict[str, Any]) -> dict[str, Any]: return await self.request("POST", "/api/fuel", telegram_id=telegram_id, json=payload) diff --git a/bot/main.py b/bot/main.py index 15f4e5b..af767b1 100644 --- a/bot/main.py +++ b/bot/main.py @@ -782,7 +782,11 @@ async def main() -> None: raise RuntimeError("INTERNAL_API_TOKEN is empty") settings.validate_webapp_url_for_telegram() bot = Bot(settings.bot_token) - await dp.start_polling(bot) + try: + await dp.start_polling(bot) + finally: + await api.close() + await bot.session.close() if __name__ == "__main__": diff --git a/scripts/load_check.py b/scripts/load_check.py new file mode 100644 index 0000000..46f2bd7 --- /dev/null +++ b/scripts/load_check.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import statistics +import time +from dataclasses import dataclass + +import httpx + + +@dataclass +class Result: + path: str + status: int | None + elapsed_ms: float + error: str | None = None + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Small HTTP concurrency smoke test for CarPass.") + parser.add_argument("--base-url", default="http://127.0.0.1:8000") + parser.add_argument("--requests", type=int, default=200) + parser.add_argument("--concurrency", type=int, default=25) + parser.add_argument( + "--path", + action="append", + dest="paths", + default=None, + help="Path to request. Can be repeated.", + ) + parser.add_argument("--timeout", type=float, default=10.0) + return parser.parse_args() + + +async def fetch(client: httpx.AsyncClient, semaphore: asyncio.Semaphore, path: str) -> Result: + async with semaphore: + started = time.perf_counter() + try: + response = await client.get(path) + elapsed_ms = (time.perf_counter() - started) * 1000 + return Result(path=path, status=response.status_code, elapsed_ms=elapsed_ms) + except Exception as error: # noqa: BLE001 + elapsed_ms = (time.perf_counter() - started) * 1000 + return Result(path=path, status=None, elapsed_ms=elapsed_ms, error=str(error)) + + +async def run() -> int: + args = parse_args() + paths = args.paths or ["/health", "/ready", "/", "/admin.html", "/sto.html"] + semaphore = asyncio.Semaphore(max(args.concurrency, 1)) + limits = httpx.Limits( + max_connections=max(args.concurrency * 2, 10), + max_keepalive_connections=max(args.concurrency, 10), + ) + timeout = httpx.Timeout(args.timeout, connect=min(args.timeout, 5.0)) + started = time.perf_counter() + async with httpx.AsyncClient(base_url=args.base_url.rstrip("/"), timeout=timeout, limits=limits) as client: + tasks = [fetch(client, semaphore, paths[index % len(paths)]) for index in range(args.requests)] + results = await asyncio.gather(*tasks) + elapsed = time.perf_counter() - started + failures = [result for result in results if result.error or not result.status or result.status >= 500] + latencies = [result.elapsed_ms for result in results] + p95 = statistics.quantiles(latencies, n=20)[18] if len(latencies) >= 20 else max(latencies, default=0) + print( + "load_check " + f"base_url={args.base_url} requests={len(results)} concurrency={args.concurrency} " + f"ok={len(results) - len(failures)} failures={len(failures)} " + f"rps={len(results) / elapsed:.2f} avg_ms={statistics.fmean(latencies):.1f} " + f"p95_ms={p95:.1f} max_ms={max(latencies, default=0):.1f}" + ) + if failures: + for result in failures[:10]: + print(f"failure path={result.path} status={result.status} error={result.error or '-'}") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(run())) diff --git a/tests/test_admin_control_center.py b/tests/test_admin_control_center.py index fdcd514..0e5f66b 100644 --- a/tests/test_admin_control_center.py +++ b/tests/test_admin_control_center.py @@ -222,6 +222,129 @@ async def test_data_query_creates_audit_log(client, admin_auth_headers, internal assert any(item["action"] == "admin.data.query" for item in audit.json()) +@pytest.mark.asyncio +async def test_admin_data_update_requires_reason_and_audits( + client, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + user = ( + await client.post( + "/api/users", + headers=internal_headers, + json={"telegram_id": 456789, "first_name": "Before"}, + ) + ).json() + + missing_reason = await client.patch( + f"/api/admin/data/users/{user['id']}", + headers=admin_auth_headers, + json={"values": {"first_name": "After"}, "reason": ""}, + ) + updated = await client.patch( + f"/api/admin/data/users/{user['id']}", + headers=admin_auth_headers, + json={"values": {"first_name": "After"}, "reason": "support correction"}, + ) + audit = await client.get("/api/admin/audit-log?action=admin.data.update", headers=admin_auth_headers) + + assert missing_reason.status_code == 400 + assert updated.status_code == 200 + assert updated.json()["row"]["first_name"] == "After" + assert any(item["action"] == "admin.data.update" for item in audit.json()) + + +@pytest.mark.asyncio +async def test_admin_data_update_rejects_forbidden_field( + client, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + user = ( + await client.post( + "/api/users", + headers=internal_headers, + json={"telegram_id": 556677, "first_name": "Nope"}, + ) + ).json() + + response = await client.patch( + f"/api/admin/data/users/{user['id']}", + headers=admin_auth_headers, + json={"values": {"telegram_id": 1}, "reason": "support correction"}, + ) + + assert response.status_code == 400 + assert "Forbidden fields" in response.text + + +@pytest.mark.asyncio +async def test_admin_data_delete_soft_blocks_user( + client, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + user = ( + await client.post( + "/api/users", + headers=internal_headers, + json={"telegram_id": 667788, "first_name": "Blocked soon"}, + ) + ).json() + + deleted = await client.request( + "DELETE", + f"/api/admin/data/users/{user['id']}", + headers=admin_auth_headers, + json={"reason": "support requested block"}, + ) + query = await client.post( + "/api/admin/data/query", + headers=admin_auth_headers, + json={"source": "users", "user_id": user["id"], "limit": 25}, + ) + audit = await client.get("/api/admin/audit-log?action=admin.data.delete", headers=admin_auth_headers) + + assert deleted.status_code == 200 + assert deleted.json()["mode"] == "soft" + assert query.json()["rows"][0]["platform_role"] == "blocked" + assert any(item["action"] == "admin.data.delete" for item in audit.json()) + + +@pytest.mark.asyncio +async def test_admin_data_delete_hard_deletes_fuel_entry( + client, auth_headers, admin_auth_headers, internal_headers +) -> None: + await ensure_admin(client, internal_headers) + car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Admin delete fuel"})).json() + fuel = ( + await client.post( + "/api/fuel", + headers=auth_headers, + json={ + "car_id": car["id"], + "entry_date": "2026-05-18", + "odometer": 1200, + "liters": 35, + "price_per_liter": 2, + }, + ) + ).json() + + deleted = await client.request( + "DELETE", + f"/api/admin/data/fuel_entries/{fuel['id']}", + headers=admin_auth_headers, + json={"reason": "duplicate record cleanup"}, + ) + query = await client.post( + "/api/admin/data/query", + headers=admin_auth_headers, + json={"source": "fuel_entries", "vehicle_id": car["id"], "limit": 25}, + ) + + assert deleted.status_code == 200 + assert deleted.json()["mode"] == "hard" + assert query.json()["rows"] == [] + + @pytest.mark.asyncio async def test_pending_sto_queue_and_approve_audit( client, auth_headers, admin_auth_headers, internal_headers diff --git a/web/admin.html b/web/admin.html index 7b305fe..3955239 100644 --- a/web/admin.html +++ b/web/admin.html @@ -206,6 +206,7 @@ +
diff --git a/web/static/admin.js b/web/static/admin.js index 2a8e8e2..7eff49a 100644 --- a/web/static/admin.js +++ b/web/static/admin.js @@ -3,6 +3,7 @@ const AdminPage = (() => { const state = { active: "dashboard", sources: [], + sourcesByName: {}, sorts: [], lastDataPayload: null, }; @@ -61,16 +62,97 @@ const AdminPage = (() => { root.innerHTML = `
${escapeHtml(error.message || "Ошибка")}
`; } - function renderTable(root, rows, preferredColumns = []) { + function sourceConfig(source) { + return source ? state.sourcesByName[source] : null; + } + + function editableValues(row, source) { + const config = sourceConfig(source); + const fields = config?.editable || []; + return fields.reduce((payload, field) => { + if (Object.prototype.hasOwnProperty.call(row, field)) payload[field] = row[field]; + return payload; + }, {}); + } + + function renderSourceHint(source) { + const hint = qs("#sourceHint"); + if (!hint) return; + const config = sourceConfig(source); + if (!config) { + hint.textContent = ""; + return; + } + const editable = config.editable?.length ? config.editable.join(", ") : "нет"; + const deleteMode = config.deletable ? config.delete_mode : "нет"; + hint.textContent = `Редактируемые поля: ${editable}. Удаление: ${deleteMode}. Все изменения требуют reason и пишутся в Audit Log.`; + } + + async function mutateRow(action, source, row) { + const config = sourceConfig(source); + if (!config || !row?.id) return; + if (action === "edit") { + const draft = editableValues(row, source); + const raw = window.prompt("JSON с изменяемыми полями", JSON.stringify(draft, null, 2)); + if (!raw) return; + let values; + try { + values = JSON.parse(raw); + } catch { + toast("Некорректный JSON", "error"); + return; + } + const reason = window.prompt("Причина изменения") || ""; + if (!reason.trim()) return; + await api(`/admin/data/${source}/${row.id}`, { + method: "PATCH", + body: JSON.stringify({ values, reason }), + }); + toast("Запись обновлена"); + } else { + const reason = window.prompt(`Причина удаления ${source} #${row.id}`) || ""; + if (!reason.trim()) return; + if (!window.confirm(`Удалить ${source} #${row.id}?`)) return; + await api(`/admin/data/${source}/${row.id}`, { + method: "DELETE", + body: JSON.stringify({ reason }), + }); + toast(config.delete_mode === "hard" ? "Запись удалена" : "Запись скрыта/отключена"); + } + await loadActiveSection(); + if (state.active === "data" && state.lastDataPayload) { + await submitDataQuery(); + } + } + + function bindTableActions(root, source, rows) { + root.querySelectorAll("[data-admin-row-action]").forEach((button) => { + button.addEventListener("click", async () => { + const row = rows[Number(button.dataset.rowIndex)]; + try { + await mutateRow(button.dataset.adminRowAction, source, row); + } catch (error) { + toast(error.message || "Ошибка", "error"); + } + }); + }); + } + + function renderTable(root, rows, preferredColumns = [], source = null) { if (!rows?.length) { renderEmpty(root); return; } const columns = preferredColumns.length ? preferredColumns : Object.keys(rows[0]); + const config = sourceConfig(source); + const hasActions = Boolean(config && rows.some((row) => row.id) && (config.editable?.length || config.deletable)); root.innerHTML = ` - ${columns.map((column) => ``).join("")} + + ${columns.map((column) => ``).join("")} + ${hasActions ? "" : ""} + ${rows @@ -78,6 +160,22 @@ const AdminPage = (() => { (row) => ` ${columns.map((column) => ``).join("")} + ${ + hasActions + ? `` + : "" + } `, ) @@ -85,6 +183,7 @@ const AdminPage = (() => {
${escapeHtml(column)}
${escapeHtml(column)}Действия
${valueOrDash(row[column])} + ${ + config.editable?.length + ? `` + : "" + } + ${ + config.deletable + ? `` + : "" + } +
`; + if (hasActions) bindTableActions(root, source, rows); } function badge(value) { @@ -174,7 +273,7 @@ const AdminPage = (() => { const query = new URLSearchParams(); if (search) query.set("search", search); const data = await api(`/admin/users?${query.toString()}`); - renderTable(qs("#usersTable"), data.rows, ["id", "telegram_id", "username", "first_name", "platform_role", "created_at"]); + renderTable(qs("#usersTable"), data.rows, ["id", "telegram_id", "username", "first_name", "platform_role", "created_at"], "users"); } async function loadSto(filters = {}) { @@ -183,7 +282,7 @@ const AdminPage = (() => { if (value) query.set(key, value); }); const data = await api(`/admin/sto?${query.toString()}`); - renderTable(qs("#stoTable"), data.rows, ["id", "display_name", "city", "phone", "verification_status", "owner_user_id", "created_at"]); + renderTable(qs("#stoTable"), data.rows, ["id", "display_name", "city", "phone", "verification_status", "owner_user_id", "created_at"], "sto_profiles"); } async function loadApplications() { @@ -233,7 +332,7 @@ const AdminPage = (() => { method: "POST", body: JSON.stringify({ source, limit: 100 }), }); - renderTable(root, data.rows, columns); + renderTable(root, data.rows, columns, source); } catch (error) { renderError(root, error); } @@ -258,6 +357,7 @@ const AdminPage = (() => { async function submitDataQuery(format = null) { const payload = cleanPayload(formData(qs("#dataForm"))); state.lastDataPayload = payload; + renderSourceHint(payload.source); if (format) { const result = await api("/admin/data/export", { method: "POST", @@ -271,7 +371,7 @@ const AdminPage = (() => { method: "POST", body: JSON.stringify(payload), }); - renderTable(qs("#dataResult"), data.rows); + renderTable(qs("#dataResult"), data.rows, [], payload.source); } async function loadAudit(params = {}) { @@ -280,12 +380,12 @@ const AdminPage = (() => { if (value) query.set(key, value); }); const rows = await api(`/admin/audit-log?${query.toString()}`); - renderTable(qs("#auditTable"), rows, ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "created_at"]); + renderTable(qs("#auditTable"), rows, ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "created_at"], "audit_logs"); } async function loadExports() { const data = await api("/admin/exports"); - renderTable(qs("#exportsTable"), data.rows, ["id", "source", "export_format", "status", "row_count", "reason", "expires_at", "created_at"]); + renderTable(qs("#exportsTable"), data.rows, ["id", "source", "export_format", "status", "row_count", "reason", "expires_at", "created_at"], "imports_exports"); } async function loadActiveSection() { @@ -349,6 +449,7 @@ const AdminPage = (() => { async function initSources() { const data = await api("/admin/data/sources"); state.sources = data.sources || []; + state.sourcesByName = Object.fromEntries(state.sources.map((source) => [source.name, source])); state.sorts = data.sorts || []; qs("#sourceSelect").innerHTML = state.sources .filter((source) => source.available && source.allowed) @@ -357,6 +458,8 @@ const AdminPage = (() => { qs("#sortSelect").innerHTML = state.sorts .map((sort) => ``) .join(""); + qs("#sourceSelect")?.addEventListener("change", (event) => renderSourceHint(event.target.value)); + renderSourceHint(qs("#sourceSelect")?.value); } async function init() { diff --git a/web/static/styles.css b/web/static/styles.css index c5ec287..f55a8dd 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -2156,6 +2156,7 @@ select { border: 1px solid var(--line); border-radius: 8px; background: #fff; + box-shadow: 0 10px 30px rgba(17, 36, 30, 0.05); } .admin-table { @@ -2167,7 +2168,7 @@ select { .admin-table th, .admin-table td { - padding: 10px; + padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; @@ -2188,6 +2189,41 @@ select { border-bottom: 0; } +.admin-table tbody tr { + transition: background 140ms ease; +} + +.admin-table tbody tr:hover { + background: #f7fbf8; +} + +.admin-actions-head, +.admin-action-cell { + position: sticky; + right: 0; + min-width: 128px; + background: #fff; + box-shadow: -10px 0 18px rgba(255, 255, 255, 0.78); +} + +.admin-table tbody tr:hover .admin-action-cell { + background: #f7fbf8; +} + +.admin-action-cell { + display: flex; + gap: 6px; + align-items: center; + border-left: 1px solid var(--line); +} + +.compact-btn { + min-height: 30px; + padding: 0 8px; + font-size: 12px; + box-shadow: none; +} + .admin-badge { display: inline-flex; min-height: 22px; @@ -2217,6 +2253,17 @@ select { gap: 8px; } +.admin-source-hint { + margin: -2px 0 10px; + padding: 10px 12px; + border: 1px solid rgba(22, 128, 106, 0.16); + border-radius: 8px; + background: #f2faf6; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + .error-state { color: var(--danger); background: #fff4f2; -- 2.49.1