add admin data mutations and load check
Some checks failed
ci / test (pull_request) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-18 18:37:19 +09:00
parent 59bc6ebd4f
commit 8982299e71
9 changed files with 650 additions and 44 deletions

View File

@@ -60,6 +60,8 @@ Data Explorer:
- `GET /api/admin/data/sources` - `GET /api/admin/data/sources`
- `POST /api/admin/data/query` - `POST /api/admin/data/query`
- `PATCH /api/admin/data/{source}/{id}`
- `DELETE /api/admin/data/{source}/{id}`
- `POST /api/admin/data/export` - `POST /api/admin/data/export`
Users: Users:
@@ -115,6 +117,16 @@ Data Explorer работает только по whitelist источников
Поддержаны фильтры по дате, статусу, пользователю, Telegram ID, авто, СТО, городу, роли, категории, сумме, ошибкам и текстовому поиску. Каждый запрос ограничен `limit` до 500 строк и пишет audit log. Поддержаны фильтры по дате, статусу, пользователю, 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 ## Privacy
По умолчанию маскируются Telegram ID, VIN, госномер, телефон и регистрационные данные СТО. По умолчанию маскируются Telegram ID, VIN, госномер, телефон и регистрационные данные СТО.
@@ -169,3 +181,13 @@ API дополнительно проверяет роль пользовате
- проверяет `/health`, `/ready`, `/metrics`, `/admin.html`, `/sto.html`, `/work_order.html`. - проверяет `/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`. Утилита `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 или сетевых сбоях.

View File

@@ -1,4 +1,5 @@
import csv import csv
import enum
import io import io
import json import json
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
@@ -83,6 +84,20 @@ class AdminUserNote(BaseModel):
note: str 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]] = { DATA_SOURCES: dict[str, dict[str, Any]] = {
"users": { "users": {
"model": User, "model": User,
@@ -91,6 +106,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"filters": {"user_id": "id", "telegram_id": "telegram_id", "role": "platform_role"}, "filters": {"user_id": "id", "telegram_id": "telegram_id", "role": "platform_role"},
"sensitive": {"telegram_id"}, "sensitive": {"telegram_id"},
"columns": ["id", "telegram_id", "username", "first_name", "last_name", "platform_role", "created_at", "updated_at"], "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": { "vehicles": {
"model": Car, "model": Car,
@@ -99,6 +116,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"filters": {"user_id": "owner_id", "vehicle_id": "id"}, "filters": {"user_id": "owner_id", "vehicle_id": "id"},
"sensitive": {"vin", "plate_number", "license_plate_display", "vin_normalized", "license_plate_normalized"}, "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"], "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": { "fuel_entries": {
"model": FuelEntry, "model": FuelEntry,
@@ -107,6 +126,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"amount": "total_cost", "amount": "total_cost",
"date": "entry_date", "date": "entry_date",
"columns": ["id", "car_id", "entry_date", "odometer", "liters", "total_cost", "created_at"], "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": { "service_entries": {
"model": ServiceEntry, "model": ServiceEntry,
@@ -116,6 +137,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"amount": "total_cost", "amount": "total_cost",
"date": "entry_date", "date": "entry_date",
"columns": ["id", "car_id", "entry_date", "service_type", "title", "total_cost", "created_at"], "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": { "expense_entries": {
"model": ExpenseEntry, "model": ExpenseEntry,
@@ -125,6 +148,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"amount": "total_cost", "amount": "total_cost",
"date": "entry_date", "date": "entry_date",
"columns": ["id", "car_id", "entry_date", "category", "title", "total_cost", "currency", "created_at"], "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": { "sto_profiles": {
"model": ServiceCenter, "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"}, "filters": {"sto_id": "id", "city": "city", "status": "verification_status", "user_id": "owner_user_id"},
"sensitive": {"phone", "contact_phone", "business_registration_number"}, "sensitive": {"phone", "contact_phone", "business_registration_number"},
"columns": ["id", "display_name", "legal_name", "city", "phone", "verification_status", "owner_user_id", "created_at", "verified_at"], "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": { "sto_applications": {
"model": ServiceCenterVerification, "model": ServiceCenterVerification,
"roles": MODERATION_ROLES, "roles": MODERATION_ROLES,
"filters": {"sto_id": "service_center_id", "status": "status", "user_id": "reviewed_by"}, "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"], "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": { "sto_employees": {
"model": ServiceEmployee, "model": ServiceEmployee,
"roles": MODERATION_ROLES | {"support"}, "roles": MODERATION_ROLES | {"support"},
"filters": {"sto_id": "service_center_id", "user_id": "user_id", "role": "role", "status": "status"}, "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"], "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": { "vehicle_sto_links": {
"model": CarServiceLink, "model": CarServiceLink,
"roles": MODERATION_ROLES | {"support"}, "roles": MODERATION_ROLES | {"support"},
"filters": {"vehicle_id": "car_id", "sto_id": "service_center_id", "status": "status"}, "filters": {"vehicle_id": "car_id", "sto_id": "service_center_id", "status": "status"},
"columns": ["id", "car_id", "service_center_id", "access_level", "status", "created_at"], "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": { "appointments": {
"model": ServiceAppointment, "model": ServiceAppointment,
"roles": ADMIN_ROLES, "roles": ADMIN_ROLES,
"filters": {"vehicle_id": "vehicle_id", "sto_id": "service_center_id", "user_id": "owner_user_id", "status": "status"}, "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"], "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": { "work_orders": {
"model": ServiceVisit, "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"}, "filters": {"vehicle_id": "vehicle_id", "sto_id": "service_center_id", "user_id": "owner_user_id", "status": "status"},
"amount": "final_total", "amount": "final_total",
"columns": ["id", "service_center_id", "vehicle_id", "owner_user_id", "status", "final_total", "currency", "created_at", "completed_at"], "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": { "work_order_items": {
"model": ServiceWorkItem, "model": ServiceWorkItem,
@@ -171,6 +208,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"filters": {"category": "category"}, "filters": {"category": "category"},
"amount": "total", "amount": "total",
"columns": ["id", "service_visit_id", "work_type", "title", "category", "quantity", "total", "created_at"], "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": { "work_order_products": {
"model": ServiceProductItem, "model": ServiceProductItem,
@@ -178,24 +217,32 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"filters": {"category": "category"}, "filters": {"category": "category"},
"amount": "total", "amount": "total",
"columns": ["id", "service_visit_id", "title", "category", "product_type", "quantity", "total", "created_at"], "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": { "reviews": {
"model": ServiceCenterReview, "model": ServiceCenterReview,
"roles": MODERATION_ROLES | {"support", "analyst"}, "roles": MODERATION_ROLES | {"support", "analyst"},
"filters": {"sto_id": "service_center_id", "user_id": "user_id", "status": "status"}, "filters": {"sto_id": "service_center_id", "user_id": "user_id", "status": "status"},
"columns": ["id", "service_center_id", "user_id", "rating", "status", "created_at"], "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": { "notifications": {
"model": ServiceNotification, "model": ServiceNotification,
"roles": FULL_ADMIN_ROLES | {"support"}, "roles": FULL_ADMIN_ROLES | {"support"},
"filters": {"user_id": "recipient_user_id", "status": "status", "sto_id": "service_center_id"}, "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"], "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": { "admin_notifications": {
"model": AdminNotification, "model": AdminNotification,
"roles": ADMIN_ROLES, "roles": ADMIN_ROLES,
"filters": {"status": "status"}, "filters": {"status": "status"},
"columns": ["id", "event_type", "severity", "title", "entity_type", "entity_id", "status", "created_at"], "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": { "audit_logs": {
"model": AuditLog, "model": AuditLog,
@@ -210,6 +257,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"roles": DATA_EXPORT_ROLES, "roles": DATA_EXPORT_ROLES,
"filters": {"user_id": "requested_by_user_id", "status": "status"}, "filters": {"user_id": "requested_by_user_id", "status": "status"},
"columns": ["id", "requested_by_user_id", "source", "export_format", "status", "row_count", "created_at"], "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 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: def apply_data_filters(stmt: Select, query: AdminDataQuery, config: dict[str, Any]) -> Select:
model = config["model"] model = config["model"]
date_column = config.get("date", "created_at") 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 [], "columns": config.get("columns") or [],
"sensitive": sorted(config.get("sensitive") or []), "sensitive": sorted(config.get("sensitive") or []),
"allowed": current_user.platform_role in set(config["roles"]), "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() for name, config in DATA_SOURCES.items()
], ],
@@ -532,6 +657,96 @@ async def admin_data_query(
return data 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") @router.post("/data/export")
async def admin_data_export( async def admin_data_export(
payload: AdminExportRequest, payload: AdminExportRequest,

View File

@@ -8,6 +8,21 @@ from app.core.config import settings
class ApiClient: class ApiClient:
def __init__(self) -> None: def __init__(self) -> None:
self.base_url = settings.api_base_url.rstrip("/") 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]: def headers(self, telegram_id: int | None = None) -> dict[str, str]:
headers = {"X-Internal-API-Token": settings.internal_api_token} headers = {"X-Internal-API-Token": settings.internal_api_token}
@@ -24,18 +39,17 @@ class ApiClient:
json: dict[str, Any] | None = None, json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> Any: ) -> Any:
async with httpx.AsyncClient(base_url=self.base_url, timeout=15) as client: response = await self.client.request(
response = await client.request( method,
method, path,
path, json=json,
json=json, params=params,
params=params, headers=self.headers(telegram_id),
headers=self.headers(telegram_id), )
) response.raise_for_status()
response.raise_for_status() if response.status_code == 204:
if response.status_code == 204: return None
return None return response.json()
return response.json()
async def upsert_user(self, telegram_user: Any) -> dict[str, Any]: async def upsert_user(self, telegram_user: Any) -> dict[str, Any]:
payload = { payload = {
@@ -44,34 +58,30 @@ class ApiClient:
"first_name": telegram_user.first_name, "first_name": telegram_user.first_name,
"last_name": telegram_user.last_name, "last_name": telegram_user.last_name,
} }
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: response = await self.client.post("/api/users", json=payload, headers=self.headers())
response = await client.post("/api/users", json=payload, headers=self.headers()) response.raise_for_status()
response.raise_for_status() return response.json()
return response.json()
async def list_cars(self, owner_id: int, telegram_id: int) -> list[dict[str, Any]]: 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 self.client.get(
response = await client.get( "/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id)
"/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id) )
) response.raise_for_status()
response.raise_for_status() return response.json()
return response.json()
async def create_car(self, owner_id: int, name: str, telegram_id: int) -> dict[str, Any]: 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 self.client.post(
response = await client.post( "/api/cars",
"/api/cars", json={"owner_id": owner_id, "name": name},
json={"owner_id": owner_id, "name": name}, headers=self.headers(telegram_id),
headers=self.headers(telegram_id), )
) response.raise_for_status()
response.raise_for_status() return response.json()
return response.json()
async def stats(self, car_id: int, telegram_id: int) -> dict[str, Any]: 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 self.client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
response = await client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id)) response.raise_for_status()
response.raise_for_status() return response.json()
return response.json()
async def create_fuel(self, telegram_id: int, payload: dict[str, Any]) -> dict[str, Any]: 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) return await self.request("POST", "/api/fuel", telegram_id=telegram_id, json=payload)

View File

@@ -782,7 +782,11 @@ async def main() -> None:
raise RuntimeError("INTERNAL_API_TOKEN is empty") raise RuntimeError("INTERNAL_API_TOKEN is empty")
settings.validate_webapp_url_for_telegram() settings.validate_webapp_url_for_telegram()
bot = Bot(settings.bot_token) 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__": if __name__ == "__main__":

81
scripts/load_check.py Normal file
View File

@@ -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()))

View File

@@ -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()) 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 @pytest.mark.asyncio
async def test_pending_sto_queue_and_approve_audit( async def test_pending_sto_queue_and_approve_audit(
client, auth_headers, admin_auth_headers, internal_headers client, auth_headers, admin_auth_headers, internal_headers

View File

@@ -206,6 +206,7 @@
<button type="button" class="ghost-btn" id="exportCsvBtn">CSV export</button> <button type="button" class="ghost-btn" id="exportCsvBtn">CSV export</button>
</div> </div>
</form> </form>
<div id="sourceHint" class="admin-source-hint"></div>
<div id="dataResult" class="admin-table-wrap"></div> <div id="dataResult" class="admin-table-wrap"></div>
</section> </section>

View File

@@ -3,6 +3,7 @@ const AdminPage = (() => {
const state = { const state = {
active: "dashboard", active: "dashboard",
sources: [], sources: [],
sourcesByName: {},
sorts: [], sorts: [],
lastDataPayload: null, lastDataPayload: null,
}; };
@@ -61,16 +62,97 @@ const AdminPage = (() => {
root.innerHTML = `<div class="tip-card error-state">${escapeHtml(error.message || "Ошибка")}</div>`; root.innerHTML = `<div class="tip-card error-state">${escapeHtml(error.message || "Ошибка")}</div>`;
} }
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) { if (!rows?.length) {
renderEmpty(root); renderEmpty(root);
return; return;
} }
const columns = preferredColumns.length ? preferredColumns : Object.keys(rows[0]); 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 = ` root.innerHTML = `
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr>${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}</tr> <tr>
${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}
${hasActions ? "<th class=\"admin-actions-head\">Действия</th>" : ""}
</tr>
</thead> </thead>
<tbody> <tbody>
${rows ${rows
@@ -78,6 +160,22 @@ const AdminPage = (() => {
(row) => ` (row) => `
<tr> <tr>
${columns.map((column) => `<td>${valueOrDash(row[column])}</td>`).join("")} ${columns.map((column) => `<td>${valueOrDash(row[column])}</td>`).join("")}
${
hasActions
? `<td class="admin-action-cell">
${
config.editable?.length
? `<button type="button" class="ghost-btn compact-btn" data-admin-row-action="edit" data-row-index="${rows.indexOf(row)}">Edit</button>`
: ""
}
${
config.deletable
? `<button type="button" class="danger-btn compact-btn" data-admin-row-action="delete" data-row-index="${rows.indexOf(row)}">Delete</button>`
: ""
}
</td>`
: ""
}
</tr> </tr>
`, `,
) )
@@ -85,6 +183,7 @@ const AdminPage = (() => {
</tbody> </tbody>
</table> </table>
`; `;
if (hasActions) bindTableActions(root, source, rows);
} }
function badge(value) { function badge(value) {
@@ -174,7 +273,7 @@ const AdminPage = (() => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (search) query.set("search", search); if (search) query.set("search", search);
const data = await api(`/admin/users?${query.toString()}`); 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 = {}) { async function loadSto(filters = {}) {
@@ -183,7 +282,7 @@ const AdminPage = (() => {
if (value) query.set(key, value); if (value) query.set(key, value);
}); });
const data = await api(`/admin/sto?${query.toString()}`); 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() { async function loadApplications() {
@@ -233,7 +332,7 @@ const AdminPage = (() => {
method: "POST", method: "POST",
body: JSON.stringify({ source, limit: 100 }), body: JSON.stringify({ source, limit: 100 }),
}); });
renderTable(root, data.rows, columns); renderTable(root, data.rows, columns, source);
} catch (error) { } catch (error) {
renderError(root, error); renderError(root, error);
} }
@@ -258,6 +357,7 @@ const AdminPage = (() => {
async function submitDataQuery(format = null) { async function submitDataQuery(format = null) {
const payload = cleanPayload(formData(qs("#dataForm"))); const payload = cleanPayload(formData(qs("#dataForm")));
state.lastDataPayload = payload; state.lastDataPayload = payload;
renderSourceHint(payload.source);
if (format) { if (format) {
const result = await api("/admin/data/export", { const result = await api("/admin/data/export", {
method: "POST", method: "POST",
@@ -271,7 +371,7 @@ const AdminPage = (() => {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
renderTable(qs("#dataResult"), data.rows); renderTable(qs("#dataResult"), data.rows, [], payload.source);
} }
async function loadAudit(params = {}) { async function loadAudit(params = {}) {
@@ -280,12 +380,12 @@ const AdminPage = (() => {
if (value) query.set(key, value); if (value) query.set(key, value);
}); });
const rows = await api(`/admin/audit-log?${query.toString()}`); 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() { async function loadExports() {
const data = await api("/admin/exports"); 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() { async function loadActiveSection() {
@@ -349,6 +449,7 @@ const AdminPage = (() => {
async function initSources() { async function initSources() {
const data = await api("/admin/data/sources"); const data = await api("/admin/data/sources");
state.sources = data.sources || []; state.sources = data.sources || [];
state.sourcesByName = Object.fromEntries(state.sources.map((source) => [source.name, source]));
state.sorts = data.sorts || []; state.sorts = data.sorts || [];
qs("#sourceSelect").innerHTML = state.sources qs("#sourceSelect").innerHTML = state.sources
.filter((source) => source.available && source.allowed) .filter((source) => source.available && source.allowed)
@@ -357,6 +458,8 @@ const AdminPage = (() => {
qs("#sortSelect").innerHTML = state.sorts qs("#sortSelect").innerHTML = state.sorts
.map((sort) => `<option value="${sort}">${sort}</option>`) .map((sort) => `<option value="${sort}">${sort}</option>`)
.join(""); .join("");
qs("#sourceSelect")?.addEventListener("change", (event) => renderSourceHint(event.target.value));
renderSourceHint(qs("#sourceSelect")?.value);
} }
async function init() { async function init() {

View File

@@ -2156,6 +2156,7 @@ select {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: #fff; background: #fff;
box-shadow: 0 10px 30px rgba(17, 36, 30, 0.05);
} }
.admin-table { .admin-table {
@@ -2167,7 +2168,7 @@ select {
.admin-table th, .admin-table th,
.admin-table td { .admin-table td {
padding: 10px; padding: 9px 10px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
@@ -2188,6 +2189,41 @@ select {
border-bottom: 0; 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 { .admin-badge {
display: inline-flex; display: inline-flex;
min-height: 22px; min-height: 22px;
@@ -2217,6 +2253,17 @@ select {
gap: 8px; 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 { .error-state {
color: var(--danger); color: var(--danger);
background: #fff4f2; background: #fff4f2;