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(column)} | `).join("")}|
|---|---|
| ${escapeHtml(column)} | `).join("")} + ${hasActions ? "Действия | " : ""} +
| ${valueOrDash(row[column])} | `).join("")} + ${ + hasActions + ? `+ ${ + config.editable?.length + ? `` + : "" + } + ${ + config.deletable + ? `` + : "" + } + | ` + : "" + }