From 8982299e712b5bd7632df56910a75ee7c2e4f3a3 Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Mon, 18 May 2026 18:37:19 +0900 Subject: [PATCH] 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;