add admin data mutations and load check
Some checks failed
ci / test (pull_request) Has been cancelled
Some checks failed
ci / test (pull_request) Has been cancelled
This commit is contained in:
22
ADMIN.md
22
ADMIN.md
@@ -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 или сетевых сбоях.
|
||||||
|
|||||||
215
app/api/admin.py
215
app/api/admin.py
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
81
scripts/load_check.py
Normal 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()))
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user