Merge pull request 'pilot-hardening-notifications-deploy' (#1) from pilot-hardening-notifications-deploy into main
Some checks failed
ci / test (push) Has been cancelled
Some checks failed
ci / test (push) Has been cancelled
Reviewed-on: https://git.smartsoltech.kr/trevor/drivers_bot/pulls/1
This commit is contained in:
@@ -22,3 +22,8 @@ OCR_PROVIDER=tesseract
|
|||||||
OCR_LANGUAGES=eng+rus+kor
|
OCR_LANGUAGES=eng+rus+kor
|
||||||
ADMIN_TELEGRAM_IDS=
|
ADMIN_TELEGRAM_IDS=
|
||||||
ADMIN_BOOTSTRAP_TOKEN=
|
ADMIN_BOOTSTRAP_TOKEN=
|
||||||
|
ADMIN_NOTIFICATION_CHAT_ID=
|
||||||
|
ADMIN_NOTIFY_NEW_USERS=true
|
||||||
|
ADMIN_NOTIFY_STO_APPLICATIONS=true
|
||||||
|
ADMIN_NOTIFY_SECURITY_EVENTS=true
|
||||||
|
ADMIN_NOTIFY_SYSTEM_ERRORS=true
|
||||||
|
|||||||
193
ADMIN.md
Normal file
193
ADMIN.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# CarPass Admin Control Center
|
||||||
|
|
||||||
|
Admin Control Center дает администраторам закрытого пилота безопасный доступ к событиям сервиса, модерации СТО, просмотру данных и экспорту без прямого SQL.
|
||||||
|
|
||||||
|
## Доступ
|
||||||
|
|
||||||
|
Админка открывается в Mini App по `/admin.html` или командой бота `/admin`.
|
||||||
|
|
||||||
|
Роли:
|
||||||
|
|
||||||
|
- `super_admin`: полный доступ к пользователям, СТО, заявкам, заказ-нарядам, расходам, audit, export и системным настройкам.
|
||||||
|
- `admin`: пользователи, СТО, модерация, заказ-наряды, базовая аналитика и экспорт без секретов.
|
||||||
|
- `moderator`: заявки СТО, отзывы, блокировки и комментарии модерации.
|
||||||
|
- `support`: поиск пользователя, авто, история действий и помощь без расширенных финансовых агрегатов.
|
||||||
|
- `analyst`: агрегированная аналитика и обезличенные выгрузки без персональных данных.
|
||||||
|
|
||||||
|
Все чувствительные admin actions пишутся в `AuditLog`.
|
||||||
|
|
||||||
|
## Уведомления
|
||||||
|
|
||||||
|
Система создает `AdminNotification` в БД и best-effort отправляет Telegram-сообщение администраторам. Ошибка Telegram не ломает бизнес-flow.
|
||||||
|
|
||||||
|
Поддержанные события:
|
||||||
|
|
||||||
|
- новый пользователь;
|
||||||
|
- первое авто пользователя;
|
||||||
|
- новая заявка СТО;
|
||||||
|
- изменение статуса заявки СТО;
|
||||||
|
- одобрение, блокировка и разблокировка СТО;
|
||||||
|
- security/system события через общий admin notification service.
|
||||||
|
|
||||||
|
Idempotency key защищает от дублей.
|
||||||
|
|
||||||
|
Env:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADMIN_TELEGRAM_IDS=123,456
|
||||||
|
ADMIN_NOTIFICATION_CHAT_ID=
|
||||||
|
ADMIN_NOTIFY_NEW_USERS=true
|
||||||
|
ADMIN_NOTIFY_STO_APPLICATIONS=true
|
||||||
|
ADMIN_NOTIFY_SECURITY_EVENTS=true
|
||||||
|
ADMIN_NOTIFY_SYSTEM_ERRORS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin API
|
||||||
|
|
||||||
|
Dashboard:
|
||||||
|
|
||||||
|
- `GET /api/admin/dashboard`
|
||||||
|
|
||||||
|
Notifications:
|
||||||
|
|
||||||
|
- `GET /api/admin/notifications`
|
||||||
|
- `POST /api/admin/notifications/{id}/read`
|
||||||
|
- `POST /api/admin/notifications/read-all`
|
||||||
|
- `POST /api/admin/notifications/retry`
|
||||||
|
- `POST /api/admin/notifications/{id}/dismiss`
|
||||||
|
|
||||||
|
Data Explorer:
|
||||||
|
|
||||||
|
- `GET /api/admin/data/sources`
|
||||||
|
- `POST /api/admin/data/query`
|
||||||
|
- `PATCH /api/admin/data/{source}/{id}`
|
||||||
|
- `DELETE /api/admin/data/{source}/{id}`
|
||||||
|
- `POST /api/admin/data/export`
|
||||||
|
|
||||||
|
Users:
|
||||||
|
|
||||||
|
- `GET /api/admin/users`
|
||||||
|
- `GET /api/admin/users/{id}`
|
||||||
|
- `GET /api/admin/users/{id}/activity`
|
||||||
|
- `POST /api/admin/users/{id}/note`
|
||||||
|
- `POST /api/admin/users/{id}/block`
|
||||||
|
- `POST /api/admin/users/{id}/unblock`
|
||||||
|
|
||||||
|
СТО:
|
||||||
|
|
||||||
|
- `GET /api/admin/sto`
|
||||||
|
- `GET /api/admin/sto/{id}`
|
||||||
|
- `GET /api/admin/sto-applications`
|
||||||
|
- `POST /api/admin/sto-applications/{id}/approve`
|
||||||
|
- `POST /api/admin/sto-applications/{id}/reject`
|
||||||
|
- `POST /api/admin/sto-applications/{id}/request-changes`
|
||||||
|
- `POST /api/admin/sto/{id}/suspend`
|
||||||
|
- `POST /api/admin/sto/{id}/unsuspend`
|
||||||
|
|
||||||
|
Audit and exports:
|
||||||
|
|
||||||
|
- `GET /api/admin/audit-log`
|
||||||
|
- `GET /api/admin/exports`
|
||||||
|
- `GET /api/admin/exports/{id}`
|
||||||
|
|
||||||
|
## Data Explorer
|
||||||
|
|
||||||
|
Data Explorer работает только по whitelist источников и полей. Произвольный SQL из UI не принимается.
|
||||||
|
|
||||||
|
Источники:
|
||||||
|
|
||||||
|
- `users`
|
||||||
|
- `vehicles`
|
||||||
|
- `fuel_entries`
|
||||||
|
- `service_entries`
|
||||||
|
- `expense_entries`
|
||||||
|
- `sto_profiles`
|
||||||
|
- `sto_applications`
|
||||||
|
- `sto_employees`
|
||||||
|
- `vehicle_sto_links`
|
||||||
|
- `appointments`
|
||||||
|
- `work_orders`
|
||||||
|
- `work_order_items`
|
||||||
|
- `work_order_products`
|
||||||
|
- `reviews`
|
||||||
|
- `notifications`
|
||||||
|
- `admin_notifications`
|
||||||
|
- `audit_logs`
|
||||||
|
- `imports_exports`
|
||||||
|
|
||||||
|
Поддержаны фильтры по дате, статусу, пользователю, Telegram ID, авто, СТО, городу, роли, категории, сумме, ошибкам и текстовому поиску. Каждый запрос ограничен `limit` до 500 строк и пишет audit log.
|
||||||
|
|
||||||
|
Редактирование и удаление из Data Explorer:
|
||||||
|
|
||||||
|
- доступно только `admin` и `super_admin`;
|
||||||
|
- работает только по whitelist полей, который возвращает `GET /api/admin/data/sources`;
|
||||||
|
- требует `reason` минимум 5 символов;
|
||||||
|
- пишет `AuditLog` с old/new values;
|
||||||
|
- для пользователей, СТО, заявок, записей и уведомлений используется soft-delete/status change;
|
||||||
|
- hard-delete разрешен только для ограниченных журналов записей, где это явно включено;
|
||||||
|
- удаление автомобиля требует `super_admin`.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
По умолчанию маскируются Telegram ID, VIN, госномер, телефон и регистрационные данные СТО.
|
||||||
|
|
||||||
|
Полный просмотр sensitive data:
|
||||||
|
|
||||||
|
- доступен только `admin` и `super_admin`;
|
||||||
|
- требует `reason`;
|
||||||
|
- пишет audit log;
|
||||||
|
- не раскрывает bot token, env, internal token, secret fields.
|
||||||
|
|
||||||
|
`analyst` видит только обезличенные или замаскированные персональные данные.
|
||||||
|
|
||||||
|
## Модерация СТО
|
||||||
|
|
||||||
|
Очередь заявок доступна в `/admin.html?section=sto-applications`.
|
||||||
|
|
||||||
|
Действия:
|
||||||
|
|
||||||
|
- approve;
|
||||||
|
- reject with reason;
|
||||||
|
- request changes with reason;
|
||||||
|
- suspend;
|
||||||
|
- unsuspend.
|
||||||
|
|
||||||
|
При изменении статуса создаются audit log, admin notification и уведомление владельцу СТО.
|
||||||
|
|
||||||
|
## Bot Commands
|
||||||
|
|
||||||
|
Админские команды бота:
|
||||||
|
|
||||||
|
- `/admin`
|
||||||
|
- `/admin_stats`
|
||||||
|
- `/admin_users`
|
||||||
|
- `/admin_sto`
|
||||||
|
- `/admin_pending_sto`
|
||||||
|
- `/admin_alerts`
|
||||||
|
|
||||||
|
API дополнительно проверяет роль пользователя, поэтому команда не дает доступа без admin-role в БД.
|
||||||
|
|
||||||
|
## Deploy Reports
|
||||||
|
|
||||||
|
Для временного rsync-деплоя есть `scripts/rsync_deploy.sh`. Скрипт:
|
||||||
|
|
||||||
|
- запускает локальные `ruff` и `pytest`;
|
||||||
|
- отправляет Telegram progress/failure/success отчеты;
|
||||||
|
- делает remote code backup без `.env`;
|
||||||
|
- синхронизирует код через `rsync`;
|
||||||
|
- собирает Docker images;
|
||||||
|
- применяет Alembic migrations;
|
||||||
|
- поднимает `api` и `bot`;
|
||||||
|
- проверяет `/health`, `/ready`, `/metrics`, `/admin.html`, `/sto.html`, `/work_order.html`.
|
||||||
|
|
||||||
|
Утилита `scripts/send_telegram_report.py` берет получателей из `ADMIN_NOTIFICATION_CHAT_ID`, `ADMIN_TELEGRAM_IDS` и, если env пустой, из пользователей БД с ролями `admin`, `super_admin`, `moderator`, `support`.
|
||||||
|
|
||||||
|
## 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 или сетевых сбоях.
|
||||||
@@ -65,6 +65,12 @@ CarPass создает рекомендации обслуживания из д
|
|||||||
|
|
||||||
Telegram-уведомления являются основным каналом закрытого пилота. Browser push уже умеет сохранять подписки в Mini App и принимать push-события в service worker, но серверная Web Push-доставка помечена как beta и не считается критическим каналом пилота.
|
Telegram-уведомления являются основным каналом закрытого пилота. Browser push уже умеет сохранять подписки в Mini App и принимать push-события в service worker, но серверная Web Push-доставка помечена как beta и не считается критическим каналом пилота.
|
||||||
|
|
||||||
|
## Администрирование
|
||||||
|
|
||||||
|
Admin Control Center доступен по `/admin.html` и через команды бота `/admin`, `/admin_stats`, `/admin_users`, `/admin_sto`, `/admin_pending_sto`, `/admin_alerts`.
|
||||||
|
|
||||||
|
Админка включает dashboard сервиса, admin notifications, очередь заявок СТО, пользователей, автомобили, записи, заказ-наряды, audit log, экспорт и безопасный Data Explorer без произвольного SQL. Подробности по ролям, privacy, env и API описаны в [ADMIN.md](ADMIN.md).
|
||||||
|
|
||||||
## Безопасность данных
|
## Безопасность данных
|
||||||
|
|
||||||
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
|
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""admin notifications and data explorer jobs
|
||||||
|
|
||||||
|
Revision ID: 202605170001
|
||||||
|
Revises: 202605160002
|
||||||
|
Create Date: 2026-05-17 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "202605170001"
|
||||||
|
down_revision: str | None = "202605160002"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"admin_notifications",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(length=24), server_default="info", nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=180), nullable=False),
|
||||||
|
sa.Column("body", sa.Text(), nullable=True),
|
||||||
|
sa.Column("entity_type", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("entity_id", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=24), server_default="unread", nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=180), nullable=False),
|
||||||
|
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("telegram_status", sa.String(length=24), server_default="pending", nullable=False),
|
||||||
|
sa.Column("telegram_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("dismissed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_admin_notifications_created_at", "admin_notifications", ["created_at"])
|
||||||
|
op.create_index("ix_admin_notifications_entity_id", "admin_notifications", ["entity_id"])
|
||||||
|
op.create_index("ix_admin_notifications_entity_type", "admin_notifications", ["entity_type"])
|
||||||
|
op.create_index("ix_admin_notifications_event_type", "admin_notifications", ["event_type"])
|
||||||
|
op.create_index("ix_admin_notifications_idempotency_key", "admin_notifications", ["idempotency_key"], unique=True)
|
||||||
|
op.create_index("ix_admin_notifications_severity", "admin_notifications", ["severity"])
|
||||||
|
op.create_index("ix_admin_notifications_status", "admin_notifications", ["status"])
|
||||||
|
op.create_index("ix_admin_notifications_telegram_status", "admin_notifications", ["telegram_status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"admin_export_jobs",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("requested_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("source", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("export_format", sa.String(length=16), server_default="json", nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=24), server_default="ready", nullable=False),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("filters_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("result_text", sa.Text(), nullable=True),
|
||||||
|
sa.Column("row_count", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_admin_export_jobs_created_at", "admin_export_jobs", ["created_at"])
|
||||||
|
op.create_index("ix_admin_export_jobs_requested_by_user_id", "admin_export_jobs", ["requested_by_user_id"])
|
||||||
|
op.create_index("ix_admin_export_jobs_source", "admin_export_jobs", ["source"])
|
||||||
|
op.create_index("ix_admin_export_jobs_status", "admin_export_jobs", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_admin_export_jobs_status", table_name="admin_export_jobs")
|
||||||
|
op.drop_index("ix_admin_export_jobs_source", table_name="admin_export_jobs")
|
||||||
|
op.drop_index("ix_admin_export_jobs_requested_by_user_id", table_name="admin_export_jobs")
|
||||||
|
op.drop_index("ix_admin_export_jobs_created_at", table_name="admin_export_jobs")
|
||||||
|
op.drop_table("admin_export_jobs")
|
||||||
|
op.drop_index("ix_admin_notifications_telegram_status", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_status", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_severity", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_idempotency_key", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_event_type", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_entity_type", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_entity_id", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_created_at", table_name="admin_notifications")
|
||||||
|
op.drop_table("admin_notifications")
|
||||||
1134
app/api/admin.py
1134
app/api/admin.py
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ from app.core.config import settings
|
|||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess
|
from app.models.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.telegram_auth import verify_webapp_init_data
|
from app.services.telegram_auth import verify_webapp_init_data
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +37,24 @@ async def get_or_create_telegram_user(
|
|||||||
if user is None:
|
if user is None:
|
||||||
user = User(**{key: value for key, value in payload.items() if value is not None})
|
user = User(**{key: value for key, value in payload.items() if value is not None})
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="user_registered",
|
||||||
|
title="Новый пользователь",
|
||||||
|
body="\n".join(
|
||||||
|
item
|
||||||
|
for item in [
|
||||||
|
f"Имя: {' '.join(part for part in [first_name, last_name] if part) or '-'}",
|
||||||
|
f"Telegram ID: {telegram_id}",
|
||||||
|
f"Username: @{username}" if username else "Username: -",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
idempotency_key=f"user_registered:{telegram_id}",
|
||||||
|
metadata={"telegram_id": telegram_id, "username": username},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
for field, value in payload.items():
|
for field, value in payload.items():
|
||||||
if value is not None:
|
if value is not None:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.schemas.service_center import (
|
|||||||
VehicleUpdate,
|
VehicleUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.user import UserRead
|
from app.schemas.user import UserRead
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.odometer import (
|
from app.services.odometer import (
|
||||||
add_odometer_history,
|
add_odometer_history,
|
||||||
recalculate_current_odometer,
|
recalculate_current_odometer,
|
||||||
@@ -381,6 +382,20 @@ async def create_vehicle(
|
|||||||
changed_by=current_user.id,
|
changed_by=current_user.id,
|
||||||
)
|
)
|
||||||
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
|
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
|
||||||
|
vehicle_count = (
|
||||||
|
await session.execute(select(Car.id).where(Car.owner_id == current_user.id).limit(2))
|
||||||
|
).scalars().all()
|
||||||
|
if len(vehicle_count) == 1:
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="vehicle_created",
|
||||||
|
title="Пользователь впервые добавил авто",
|
||||||
|
body=f"{current_user.first_name or current_user.username or current_user.telegram_id}: {car.name}",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=car.id,
|
||||||
|
idempotency_key=f"first_vehicle:{current_user.id}",
|
||||||
|
metadata={"user_id": current_user.id, "vehicle_id": car.id},
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(car)
|
await session.refresh(car)
|
||||||
return car
|
return car
|
||||||
|
|||||||
142
app/api/ocr.py
142
app/api/ocr.py
@@ -1,14 +1,16 @@
|
|||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_telegram_user
|
from app.api.deps import get_current_telegram_user
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.ocr_provider import get_ocr_provider
|
from app.services.ocr_provider import get_ocr_provider
|
||||||
from app.services.rate_limit import check_rate_limit
|
from app.services.rate_limit import check_rate_limit
|
||||||
from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
|
from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
|
||||||
@@ -40,6 +42,72 @@ class OCRResultRead(BaseModel):
|
|||||||
provider: str = "heuristic"
|
provider: str = "heuristic"
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_ocr_upload(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
current_user: User,
|
||||||
|
content: bytes,
|
||||||
|
filename: str | None,
|
||||||
|
content_type: str | None,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
return validate_upload(
|
||||||
|
content=content,
|
||||||
|
filename=filename,
|
||||||
|
content_type=content_type,
|
||||||
|
max_bytes=MAX_OCR_FILE_BYTES,
|
||||||
|
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="upload_blocked",
|
||||||
|
title="Upload blocked",
|
||||||
|
body=f"OCR upload blocked: {filename or '-'}\nReason: {exc.detail}",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=current_user.id,
|
||||||
|
severity="warning",
|
||||||
|
idempotency_key=(
|
||||||
|
f"upload_blocked:{current_user.id}:{filename or 'upload'}:{exc.status_code}:"
|
||||||
|
f"{int(time.time() // 60)}"
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"filename": filename,
|
||||||
|
"content_type": content_type,
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
"detail": exc.detail,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def recognize_with_alert(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
current_user: User,
|
||||||
|
content: bytes,
|
||||||
|
filename: str | None,
|
||||||
|
scope: str,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await get_ocr_provider().recognize(content, filename)
|
||||||
|
except Exception as exc: # noqa: BLE001 - OCR must fail gracefully and alert admins
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="ocr_failed",
|
||||||
|
title="OCR provider failed",
|
||||||
|
body=f"Scope: {scope}\nFile: {filename or '-'}\nError: {type(exc).__name__}",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=current_user.id,
|
||||||
|
severity="error",
|
||||||
|
idempotency_key=f"ocr_failed:{scope}:{current_user.id}:{int(time.time() // 60)}",
|
||||||
|
metadata={"scope": scope, "filename": filename, "error_type": type(exc).__name__},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
||||||
async def parse_text_receipt(
|
async def parse_text_receipt(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -49,17 +117,23 @@ async def parse_text_receipt(
|
|||||||
) -> ReceiptSuggestion:
|
) -> ReceiptSuggestion:
|
||||||
await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session)
|
await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session)
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
validate_upload(
|
await validate_ocr_upload(
|
||||||
content=content,
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
filename=file.filename,
|
filename=file.filename,
|
||||||
content_type=file.content_type,
|
content_type=file.content_type,
|
||||||
max_bytes=MAX_OCR_FILE_BYTES,
|
content=content,
|
||||||
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
|
|
||||||
)
|
)
|
||||||
content_type = (file.content_type or "").lower()
|
content_type = (file.content_type or "").lower()
|
||||||
if content_type.startswith("image/") or content_type == "application/pdf":
|
if content_type.startswith("image/") or content_type == "application/pdf":
|
||||||
result = await get_ocr_provider().recognize(content, file.filename)
|
result = await recognize_with_alert(
|
||||||
if not result.recognized_text:
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
scope="parse_text_receipt",
|
||||||
|
)
|
||||||
|
if not result or not result.recognized_text:
|
||||||
return ReceiptSuggestion(
|
return ReceiptSuggestion(
|
||||||
confidence=0,
|
confidence=0,
|
||||||
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
|
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
|
||||||
@@ -133,8 +207,22 @@ async def recognize_license_plate(
|
|||||||
) -> OCRResultRead:
|
) -> OCRResultRead:
|
||||||
await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
await validate_ocr_upload(
|
||||||
result = await get_ocr_provider().recognize(content, file.filename)
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
)
|
||||||
|
result = await recognize_with_alert(
|
||||||
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
scope="license_plate",
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||||
return OCRResultRead(
|
return OCRResultRead(
|
||||||
recognized_text=result.recognized_text,
|
recognized_text=result.recognized_text,
|
||||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
||||||
@@ -151,8 +239,22 @@ async def recognize_vin(
|
|||||||
) -> OCRResultRead:
|
) -> OCRResultRead:
|
||||||
await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
await validate_ocr_upload(
|
||||||
result = await get_ocr_provider().recognize(content, file.filename)
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
)
|
||||||
|
result = await recognize_with_alert(
|
||||||
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
scope="vin",
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||||
return OCRResultRead(
|
return OCRResultRead(
|
||||||
recognized_text=result.recognized_text,
|
recognized_text=result.recognized_text,
|
||||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
||||||
@@ -169,8 +271,22 @@ async def recognize_service_document(
|
|||||||
) -> OCRResultRead:
|
) -> OCRResultRead:
|
||||||
await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
await validate_ocr_upload(
|
||||||
result = await get_ocr_provider().recognize(content, file.filename)
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
)
|
||||||
|
result = await recognize_with_alert(
|
||||||
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
scope="service_document",
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||||
return OCRResultRead(
|
return OCRResultRead(
|
||||||
recognized_text=result.recognized_text,
|
recognized_text=result.recognized_text,
|
||||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from app.schemas.service_center import (
|
|||||||
VehicleSearchRequest,
|
VehicleSearchRequest,
|
||||||
VehicleSearchResult,
|
VehicleSearchResult,
|
||||||
)
|
)
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.notifications import notify_platform_moderators
|
from app.services.notifications import notify_platform_moderators
|
||||||
from app.services.odometer import validate_odometer_change
|
from app.services.odometer import validate_odometer_change
|
||||||
from app.services.rate_limit import check_rate_limit
|
from app.services.rate_limit import check_rate_limit
|
||||||
@@ -147,6 +148,25 @@ async def create_service_center(
|
|||||||
target_type="service_center",
|
target_type="service_center",
|
||||||
target_id=center.id,
|
target_id=center.id,
|
||||||
)
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="sto_application_created",
|
||||||
|
title="Новая заявка СТО",
|
||||||
|
body="\n".join(
|
||||||
|
item
|
||||||
|
for item in [
|
||||||
|
f"Название: {center.display_name or center.name}",
|
||||||
|
f"Город: {center.city or '-'}",
|
||||||
|
f"Телефон: {center.phone or center.contact_phone or '-'}",
|
||||||
|
f"Документы: {len(center.document_photo_urls or [])}",
|
||||||
|
"Статус: pending",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
entity_type="service_center",
|
||||||
|
entity_id=center.id,
|
||||||
|
idempotency_key=f"sto_application_created:{center.id}",
|
||||||
|
metadata={"city": center.city, "owner_user_id": current_user.id},
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(center)
|
await session.refresh(center)
|
||||||
await notify_platform_moderators(
|
await notify_platform_moderators(
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class Settings(BaseSettings):
|
|||||||
ocr_languages: str = "eng+rus+kor"
|
ocr_languages: str = "eng+rus+kor"
|
||||||
admin_telegram_ids: str = ""
|
admin_telegram_ids: str = ""
|
||||||
admin_bootstrap_token: str = ""
|
admin_bootstrap_token: str = ""
|
||||||
|
admin_notification_chat_id: str = ""
|
||||||
|
admin_notify_new_users: bool = True
|
||||||
|
admin_notify_sto_applications: bool = True
|
||||||
|
admin_notify_security_events: bool = True
|
||||||
|
admin_notify_system_errors: bool = True
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
|||||||
26
app/main.py
26
app/main.py
@@ -25,7 +25,8 @@ from app.api import (
|
|||||||
work_orders,
|
work_orders,
|
||||||
)
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import get_session
|
from app.db.session import async_session_factory, get_session
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.rate_limit import get_redis_client
|
from app.services.rate_limit import get_redis_client
|
||||||
|
|
||||||
|
|
||||||
@@ -49,8 +50,29 @@ async def production_headers_and_metrics(request: Request, call_next):
|
|||||||
start = monotonic()
|
start = monotonic()
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
REQUEST_ERRORS += 1
|
REQUEST_ERRORS += 1
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="system_error",
|
||||||
|
title="Unhandled API error",
|
||||||
|
body=f"{request.method} {request.url.path}\nError: {type(exc).__name__}",
|
||||||
|
entity_type="system",
|
||||||
|
entity_id=request.url.path,
|
||||||
|
severity="error",
|
||||||
|
idempotency_key=f"system_error:{request.url.path}:{type(exc).__name__}:{int(start // 60)}",
|
||||||
|
metadata={
|
||||||
|
"path": request.url.path,
|
||||||
|
"method": request.method,
|
||||||
|
"request_id": request_id,
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
raise
|
raise
|
||||||
duration = monotonic() - start
|
duration = monotonic() - start
|
||||||
REQUEST_COUNT += 1
|
REQUEST_COUNT += 1
|
||||||
|
|||||||
@@ -432,6 +432,29 @@ class ServiceNotification(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminNotification(Base):
|
||||||
|
__tablename__ = "admin_notifications"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
severity: Mapped[str] = mapped_column(String(24), default="info", server_default="info", index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(180))
|
||||||
|
body: Mapped[str | None] = mapped_column(Text)
|
||||||
|
entity_type: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||||
|
entity_id: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="unread", server_default="unread", index=True)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(180), unique=True, index=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
telegram_status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
|
||||||
|
telegram_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ServiceWorkItem(Base):
|
class ServiceWorkItem(Base):
|
||||||
__tablename__ = "service_work_items"
|
__tablename__ = "service_work_items"
|
||||||
|
|
||||||
@@ -625,3 +648,19 @@ class AuditLog(Base):
|
|||||||
user_agent: Mapped[str | None] = mapped_column(String(256))
|
user_agent: Mapped[str | None] = mapped_column(String(256))
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON)
|
metadata_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminExportJob(Base):
|
||||||
|
__tablename__ = "admin_export_jobs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
export_format: Mapped[str] = mapped_column(String(16), default="json", server_default="json")
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="ready", server_default="ready", index=True)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text)
|
||||||
|
filters_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
result_text: Mapped[str | None] = mapped_column(Text)
|
||||||
|
row_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
|||||||
166
app/services/admin_notifications.py
Normal file
166
app/services/admin_notifications.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.car import AdminNotification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_EVENT_FLAGS = {
|
||||||
|
"user_registered": "admin_notify_new_users",
|
||||||
|
"vehicle_created": "admin_notify_new_users",
|
||||||
|
"first_record_created": "admin_notify_new_users",
|
||||||
|
"sto_application_created": "admin_notify_sto_applications",
|
||||||
|
"sto_application_updated": "admin_notify_sto_applications",
|
||||||
|
"sto_approved": "admin_notify_sto_applications",
|
||||||
|
"sto_suspended": "admin_notify_sto_applications",
|
||||||
|
"security_event": "admin_notify_security_events",
|
||||||
|
"rate_limit_exceeded": "admin_notify_security_events",
|
||||||
|
"upload_blocked": "admin_notify_security_events",
|
||||||
|
"system_error": "admin_notify_system_errors",
|
||||||
|
"ocr_failed": "admin_notify_system_errors",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_event_enabled(event_type: str) -> bool:
|
||||||
|
flag = ADMIN_EVENT_FLAGS.get(event_type)
|
||||||
|
return bool(getattr(settings, flag, True)) if flag else True
|
||||||
|
|
||||||
|
|
||||||
|
def admin_recipients() -> list[str]:
|
||||||
|
recipients: list[str] = []
|
||||||
|
if settings.admin_notification_chat_id:
|
||||||
|
recipients.append(settings.admin_notification_chat_id)
|
||||||
|
recipients.extend(str(item) for item in settings.admin_telegram_id_list)
|
||||||
|
return list(dict.fromkeys(recipients))
|
||||||
|
|
||||||
|
|
||||||
|
def admin_notification_url(entity_type: str | None = None, entity_id: str | int | None = None) -> str:
|
||||||
|
base = settings.effective_webapp_url
|
||||||
|
if entity_type == "service_center" and entity_id:
|
||||||
|
return f"{base}/admin.html?section=sto-applications&entity_id={entity_id}"
|
||||||
|
if entity_type == "user" and entity_id:
|
||||||
|
return f"{base}/admin.html?section=users&entity_id={entity_id}"
|
||||||
|
return f"{base}/admin.html"
|
||||||
|
|
||||||
|
|
||||||
|
async def create_admin_notification(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
title: str,
|
||||||
|
body: str | None = None,
|
||||||
|
entity_type: str | None = None,
|
||||||
|
entity_id: int | str | None = None,
|
||||||
|
severity: str = "info",
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
send_telegram: bool = True,
|
||||||
|
) -> AdminNotification:
|
||||||
|
key = idempotency_key or f"{event_type}:{entity_type or 'system'}:{entity_id or title}"
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(AdminNotification).where(AdminNotification.idempotency_key == key))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
notification = AdminNotification(
|
||||||
|
event_type=event_type,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=str(entity_id) if entity_id is not None else None,
|
||||||
|
severity=severity,
|
||||||
|
idempotency_key=key,
|
||||||
|
metadata_json=metadata,
|
||||||
|
telegram_status="pending" if send_telegram else "skipped",
|
||||||
|
)
|
||||||
|
session.add(notification)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
if send_telegram and admin_event_enabled(event_type):
|
||||||
|
await send_admin_telegram_notification(notification)
|
||||||
|
elif send_telegram:
|
||||||
|
notification.telegram_status = "skipped"
|
||||||
|
return notification
|
||||||
|
|
||||||
|
|
||||||
|
async def send_admin_telegram_notification(notification: AdminNotification) -> None:
|
||||||
|
recipients = admin_recipients()
|
||||||
|
if not recipients or not settings.bot_token:
|
||||||
|
notification.telegram_status = "skipped"
|
||||||
|
return
|
||||||
|
|
||||||
|
link = admin_notification_url(notification.entity_type, notification.entity_id)
|
||||||
|
text = "\n".join(
|
||||||
|
item
|
||||||
|
for item in [
|
||||||
|
f"<b>{escape(notification.title)}</b>",
|
||||||
|
escape(notification.body or ""),
|
||||||
|
f"Событие: <code>{escape(notification.event_type)}</code>",
|
||||||
|
f"Открыть: {escape(link)}",
|
||||||
|
]
|
||||||
|
if item
|
||||||
|
)
|
||||||
|
errors: list[str] = []
|
||||||
|
async with httpx.AsyncClient(timeout=8) as client:
|
||||||
|
for chat_id in recipients:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
f"https://api.telegram.org/bot{settings.bot_token}/sendMessage",
|
||||||
|
json={
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"disable_web_page_preview": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception as error: # noqa: BLE001 - notification delivery is best-effort
|
||||||
|
logger.warning("Admin Telegram notification failed: %s", error)
|
||||||
|
errors.append(str(error))
|
||||||
|
if errors:
|
||||||
|
notification.telegram_status = "failed"
|
||||||
|
notification.telegram_error = "; ".join(errors)[:2000]
|
||||||
|
else:
|
||||||
|
notification.telegram_status = "sent"
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_admin_telegram_notifications(session: AsyncSession, *, limit: int = 50) -> int:
|
||||||
|
result = await session.execute(
|
||||||
|
select(AdminNotification)
|
||||||
|
.where(AdminNotification.telegram_status.in_(["pending", "failed"]))
|
||||||
|
.order_by(AdminNotification.created_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
delivered = 0
|
||||||
|
for notification in result.scalars():
|
||||||
|
await send_admin_telegram_notification(notification)
|
||||||
|
if notification.telegram_status == "sent":
|
||||||
|
delivered += 1
|
||||||
|
await session.commit()
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_admin_notification_read(
|
||||||
|
session: AsyncSession, notification: AdminNotification
|
||||||
|
) -> AdminNotification:
|
||||||
|
notification.status = "read"
|
||||||
|
notification.read_at = datetime.now(UTC)
|
||||||
|
await session.flush()
|
||||||
|
return notification
|
||||||
|
|
||||||
|
|
||||||
|
async def dismiss_admin_notification(
|
||||||
|
session: AsyncSession, notification: AdminNotification
|
||||||
|
) -> AdminNotification:
|
||||||
|
notification.status = "dismissed"
|
||||||
|
notification.dismissed_at = datetime.now(UTC)
|
||||||
|
await session.flush()
|
||||||
|
return notification
|
||||||
@@ -41,7 +41,13 @@ async def check_rate_limit(
|
|||||||
if settings.redis_url:
|
if settings.redis_url:
|
||||||
allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds)
|
allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
await log_rate_limit_event(session, scope=scope, identifier="redis")
|
await log_rate_limit_event(
|
||||||
|
session,
|
||||||
|
scope=scope,
|
||||||
|
identifier="redis",
|
||||||
|
user=user,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
raise_rate_limit(scope, window_seconds)
|
raise_rate_limit(scope, window_seconds)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -52,7 +58,13 @@ async def check_rate_limit(
|
|||||||
while bucket and now - bucket[0] > window_seconds:
|
while bucket and now - bucket[0] > window_seconds:
|
||||||
bucket.popleft()
|
bucket.popleft()
|
||||||
if len(bucket) >= limit:
|
if len(bucket) >= limit:
|
||||||
await log_rate_limit_event(session, scope=scope, identifier=str(identifier))
|
await log_rate_limit_event(
|
||||||
|
session,
|
||||||
|
scope=scope,
|
||||||
|
identifier=str(identifier),
|
||||||
|
user=user,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
raise_rate_limit(scope, window_seconds)
|
raise_rate_limit(scope, window_seconds)
|
||||||
for identifier in identifiers:
|
for identifier in identifiers:
|
||||||
_buckets[(scope, identifier)].append(now)
|
_buckets[(scope, identifier)].append(now)
|
||||||
@@ -107,18 +119,82 @@ async def log_rate_limit_event(
|
|||||||
*,
|
*,
|
||||||
scope: str,
|
scope: str,
|
||||||
identifier: str,
|
identifier: str,
|
||||||
|
user: User | None = None,
|
||||||
|
request: Request | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if session is None:
|
client_host = request.client.host if request and request.client else None
|
||||||
return
|
user_agent = request.headers.get("user-agent") if request else None
|
||||||
from app.models.car import AuditLog
|
metadata = {
|
||||||
|
"scope": scope,
|
||||||
|
"identifier": identifier,
|
||||||
|
"telegram_id": user.telegram_id if user else None,
|
||||||
|
"user_id": user.id if user else None,
|
||||||
|
"ip": client_host,
|
||||||
|
}
|
||||||
|
|
||||||
session.add(
|
if session is None:
|
||||||
AuditLog(
|
from app.db.session import async_session_factory
|
||||||
actor_user_id=None,
|
|
||||||
actor_role="system",
|
async with async_session_factory() as event_session:
|
||||||
action="rate_limit.exceeded",
|
await persist_rate_limit_event(
|
||||||
target_type=scope,
|
event_session,
|
||||||
target_id=identifier[:80],
|
scope=scope,
|
||||||
metadata_json={"scope": scope, "identifier": identifier},
|
identifier=identifier,
|
||||||
)
|
user=user,
|
||||||
|
client_host=client_host,
|
||||||
|
user_agent=user_agent,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await persist_rate_limit_event(
|
||||||
|
session,
|
||||||
|
scope=scope,
|
||||||
|
identifier=identifier,
|
||||||
|
user=user,
|
||||||
|
client_host=client_host,
|
||||||
|
user_agent=user_agent,
|
||||||
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_rate_limit_event(
|
||||||
|
event_session: AsyncSession,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
identifier: str,
|
||||||
|
user: User | None,
|
||||||
|
client_host: str | None,
|
||||||
|
user_agent: str | None,
|
||||||
|
metadata: dict,
|
||||||
|
) -> None:
|
||||||
|
from app.models.car import AuditLog
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
|
|
||||||
|
try:
|
||||||
|
event_session.add(
|
||||||
|
AuditLog(
|
||||||
|
actor_user_id=user.id if user else None,
|
||||||
|
actor_role=user.platform_role if user else "system",
|
||||||
|
action="rate_limit.exceeded",
|
||||||
|
target_type=scope,
|
||||||
|
target_id=identifier[:80],
|
||||||
|
metadata_json=metadata,
|
||||||
|
ip=client_host,
|
||||||
|
user_agent=user_agent[:256] if user_agent else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
event_session,
|
||||||
|
event_type="rate_limit_exceeded",
|
||||||
|
title="Rate limit exceeded",
|
||||||
|
body=f"Scope: {scope}\nIdentifier: {identifier}",
|
||||||
|
entity_type="user" if user else "system",
|
||||||
|
entity_id=user.id if user else scope,
|
||||||
|
severity="warning",
|
||||||
|
idempotency_key=f"rate_limit:{scope}:{identifier}:{int(time.time() // max(60, 1))}",
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
await event_session.commit()
|
||||||
|
except Exception:
|
||||||
|
await event_session.rollback()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -126,6 +136,15 @@ class ApiClient:
|
|||||||
async def pending_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
|
async def pending_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
|
||||||
return await self.request("GET", "/api/admin/service-centers/pending", telegram_id=telegram_id)
|
return await self.request("GET", "/api/admin/service-centers/pending", telegram_id=telegram_id)
|
||||||
|
|
||||||
|
async def admin_dashboard(self, telegram_id: int) -> dict[str, Any]:
|
||||||
|
return await self.request("GET", "/api/admin/dashboard", telegram_id=telegram_id)
|
||||||
|
|
||||||
|
async def admin_users(self, telegram_id: int) -> dict[str, Any]:
|
||||||
|
return await self.request("GET", "/api/admin/users", telegram_id=telegram_id, params={"limit": 10})
|
||||||
|
|
||||||
|
async def admin_alerts(self, telegram_id: int) -> dict[str, Any]:
|
||||||
|
return await self.request("GET", "/api/admin/notifications", telegram_id=telegram_id, params={"limit": 10})
|
||||||
|
|
||||||
async def moderate_service_center(
|
async def moderate_service_center(
|
||||||
self,
|
self,
|
||||||
telegram_id: int,
|
telegram_id: int,
|
||||||
|
|||||||
86
bot/main.py
86
bot/main.py
@@ -433,6 +433,7 @@ async def register_sto(message: Message, command: CommandObject) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@dp.message(Command("admin_sto_pending"))
|
@dp.message(Command("admin_sto_pending"))
|
||||||
|
@dp.message(Command("admin_pending_sto"))
|
||||||
async def admin_sto_pending(message: Message) -> None:
|
async def admin_sto_pending(message: Message) -> None:
|
||||||
await upsert(message)
|
await upsert(message)
|
||||||
try:
|
try:
|
||||||
@@ -459,6 +460,77 @@ async def admin_sto_pending(message: Message) -> None:
|
|||||||
await message.answer(text, reply_markup=admin_card_keyboard(center["id"]))
|
await message.answer(text, reply_markup=admin_card_keyboard(center["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(Command("admin"))
|
||||||
|
async def admin_home(message: Message) -> None:
|
||||||
|
await upsert(message)
|
||||||
|
try:
|
||||||
|
await api.admin_dashboard(message.from_user.id)
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
await message.answer(f"Админка недоступна: {error.response.text}")
|
||||||
|
return
|
||||||
|
await message.answer(
|
||||||
|
"Admin Control Center: уведомления, пользователи, СТО, заявки, Data Explorer и Audit Log.",
|
||||||
|
reply_markup=webapp_inline_keyboard("Открыть админку", "admin.html"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(Command("admin_stats"))
|
||||||
|
async def admin_stats(message: Message) -> None:
|
||||||
|
await upsert(message)
|
||||||
|
try:
|
||||||
|
dashboard = await api.admin_dashboard(message.from_user.id)
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
await message.answer(f"Нет доступа к admin stats: {error.response.text}")
|
||||||
|
return
|
||||||
|
await message.answer(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"Admin stats",
|
||||||
|
f"Users today: {dashboard['users_today']}",
|
||||||
|
f"Users total: {dashboard['users_total']}",
|
||||||
|
f"STO pending: {dashboard['pending_sto_applications']}",
|
||||||
|
f"Appointments today: {dashboard['appointments_today']}",
|
||||||
|
f"Work orders active: {dashboard['active_work_orders']}",
|
||||||
|
f"Errors/security: {dashboard['system_errors']} / {dashboard['security_events']}",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
reply_markup=webapp_inline_keyboard("Admin dashboard", "admin.html"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(Command("admin_users"))
|
||||||
|
async def admin_users(message: Message) -> None:
|
||||||
|
await upsert(message)
|
||||||
|
try:
|
||||||
|
data = await api.admin_users(message.from_user.id)
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
await message.answer(f"Нет доступа к admin users: {error.response.text}")
|
||||||
|
return
|
||||||
|
lines = ["Последние пользователи:"]
|
||||||
|
for row in data.get("rows", [])[:10]:
|
||||||
|
lines.append(f"#{row.get('id')} {row.get('username') or '-'} · {row.get('platform_role')} · {row.get('created_at')}")
|
||||||
|
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Users", "admin.html?section=users"))
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(Command("admin_sto"))
|
||||||
|
async def admin_sto(message: Message) -> None:
|
||||||
|
await admin_sto_pending(message)
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(Command("admin_alerts"))
|
||||||
|
async def admin_alerts(message: Message) -> None:
|
||||||
|
await upsert(message)
|
||||||
|
try:
|
||||||
|
data = await api.admin_alerts(message.from_user.id)
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
await message.answer(f"Нет доступа к admin alerts: {error.response.text}")
|
||||||
|
return
|
||||||
|
lines = ["Admin alerts:"]
|
||||||
|
for row in data.get("rows", [])[:10]:
|
||||||
|
lines.append(f"#{row.get('id')} {row.get('severity')} · {row.get('title')} · {row.get('status')}")
|
||||||
|
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Alerts", "admin.html?section=notifications"))
|
||||||
|
|
||||||
|
|
||||||
async def admin_action(message: Message, command: CommandObject, action: str) -> None:
|
async def admin_action(message: Message, command: CommandObject, action: str) -> None:
|
||||||
args = (command.args or "").split(maxsplit=1)
|
args = (command.args or "").split(maxsplit=1)
|
||||||
if not args:
|
if not args:
|
||||||
@@ -577,7 +649,14 @@ async def admin_callback(callback: CallbackQuery) -> None:
|
|||||||
@dp.message(F.text == "Помощь")
|
@dp.message(F.text == "Помощь")
|
||||||
@dp.message(Command("help"))
|
@dp.message(Command("help"))
|
||||||
async def help_message(message: Message) -> None:
|
async def help_message(message: Message) -> None:
|
||||||
|
user = await api.upsert_user(message.from_user)
|
||||||
centers = await sto_workplace_centers(message.from_user.id)
|
centers = await sto_workplace_centers(message.from_user.id)
|
||||||
|
admin_help = (
|
||||||
|
"Админ: /admin — панель, /admin_stats — метрики, /admin_users — последние пользователи, "
|
||||||
|
"/admin_pending_sto — заявки СТО, /admin_alerts — события.\n"
|
||||||
|
if user.get("platform_role") in {"admin", "super_admin", "moderator", "support", "analyst"}
|
||||||
|
else ""
|
||||||
|
)
|
||||||
sto_workplace_help = (
|
sto_workplace_help = (
|
||||||
"• /sto_bookings или /sto_workplace — панель подтвержденного СТО;\n"
|
"• /sto_bookings или /sto_workplace — панель подтвержденного СТО;\n"
|
||||||
"• /accept_sto_invite <token> — принять приглашение сотрудника;\n"
|
"• /accept_sto_invite <token> — принять приглашение сотрудника;\n"
|
||||||
@@ -601,6 +680,7 @@ async def help_message(message: Message) -> None:
|
|||||||
"• /sto — каталог проверенных СТО;\n"
|
"• /sto — каталог проверенных СТО;\n"
|
||||||
"• /appointments — мои записи в СТО;\n"
|
"• /appointments — мои записи в СТО;\n"
|
||||||
f"{sto_workplace_help}"
|
f"{sto_workplace_help}"
|
||||||
|
f"{admin_help}"
|
||||||
"\n"
|
"\n"
|
||||||
"Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n"
|
"Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n"
|
||||||
f"{sto_business_help}"
|
f"{sto_business_help}"
|
||||||
@@ -702,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()))
|
||||||
96
scripts/rsync_deploy.sh
Executable file
96
scripts/rsync_deploy.sh
Executable file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
REMOTE="${REMOTE:-root@drivers.smartsoltech.kr}"
|
||||||
|
REMOTE_DIR="${REMOTE_DIR:-/opt/drivers_bot}"
|
||||||
|
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
|
||||||
|
COMPOSE="${COMPOSE:-docker compose}"
|
||||||
|
RUN_LOCAL_CHECKS="${RUN_LOCAL_CHECKS:-true}"
|
||||||
|
BACKUP_BEFORE_DEPLOY="${BACKUP_BEFORE_DEPLOY:-true}"
|
||||||
|
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
||||||
|
REVISION="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||||
|
|
||||||
|
EXCLUDES=(
|
||||||
|
"--exclude=.git/"
|
||||||
|
"--exclude=.env"
|
||||||
|
"--exclude=.env.*"
|
||||||
|
"--exclude=.venv/"
|
||||||
|
"--exclude=venv/"
|
||||||
|
"--exclude=__pycache__/"
|
||||||
|
"--exclude=.pytest_cache/"
|
||||||
|
"--exclude=.ruff_cache/"
|
||||||
|
"--exclude=.history/"
|
||||||
|
"--exclude=backups/"
|
||||||
|
"--exclude=*.sqlite"
|
||||||
|
"--exclude=*.sqlite3"
|
||||||
|
"--exclude=*.db"
|
||||||
|
)
|
||||||
|
|
||||||
|
send_remote_report() {
|
||||||
|
local text="$1"
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && export CARPASS_REPORT_TEXT=\$(cat); if $COMPOSE exec -T api test -f scripts/send_telegram_report.py >/dev/null 2>&1; then $COMPOSE exec -T -e CARPASS_REPORT_TEXT api python scripts/send_telegram_report.py; else $COMPOSE run --rm --no-deps -e CARPASS_REPORT_TEXT api python scripts/send_telegram_report.py; fi" <<<"$text" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
fail_report() {
|
||||||
|
local line="${1:-unknown}"
|
||||||
|
send_remote_report "❌ CarPass rsync deploy failed
|
||||||
|
Branch: $BRANCH
|
||||||
|
Revision: $REVISION
|
||||||
|
Step line: $line
|
||||||
|
Target: $REMOTE"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'fail_report "$LINENO"' ERR
|
||||||
|
|
||||||
|
if [[ "$RUN_LOCAL_CHECKS" == "true" ]]; then
|
||||||
|
echo "Running local checks..."
|
||||||
|
.venv/bin/ruff check app bot tests
|
||||||
|
.venv/bin/pytest -q
|
||||||
|
fi
|
||||||
|
|
||||||
|
send_remote_report "🚀 CarPass rsync deploy started
|
||||||
|
Branch: $BRANCH
|
||||||
|
Revision: $REVISION
|
||||||
|
Target: $REMOTE
|
||||||
|
Checks: local=${RUN_LOCAL_CHECKS}"
|
||||||
|
|
||||||
|
echo "Checking remote..."
|
||||||
|
ssh "$REMOTE" "test -d '$REMOTE_DIR' && test -f '$REMOTE_DIR/docker-compose.yml'"
|
||||||
|
|
||||||
|
if [[ "$BACKUP_BEFORE_DEPLOY" == "true" ]]; then
|
||||||
|
echo "Creating remote code backup..."
|
||||||
|
ssh "$REMOTE" "cd '$(dirname "$REMOTE_DIR")' && mkdir -p '$REMOTE_DIR/backups' && tar --exclude='$(basename "$REMOTE_DIR")/backups' --exclude='$(basename "$REMOTE_DIR")/.git' --exclude='$(basename "$REMOTE_DIR")/.env' --exclude='$(basename "$REMOTE_DIR")/.venv' -czf '$REMOTE_DIR/backups/code_pre_rsync_$(date +%Y%m%d%H%M%S).tgz' '$(basename "$REMOTE_DIR")'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Syncing code with rsync..."
|
||||||
|
rsync -az --delete "${EXCLUDES[@]}" ./ "$REMOTE:$REMOTE_DIR/"
|
||||||
|
|
||||||
|
echo "Building remote images..."
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && $COMPOSE build"
|
||||||
|
send_remote_report "🧱 CarPass rsync deploy progress
|
||||||
|
Branch: $BRANCH
|
||||||
|
Step: docker build completed"
|
||||||
|
|
||||||
|
echo "Applying migrations..."
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && $COMPOSE run --rm api alembic upgrade head"
|
||||||
|
send_remote_report "🗄️ CarPass rsync deploy progress
|
||||||
|
Branch: $BRANCH
|
||||||
|
Step: migrations applied"
|
||||||
|
|
||||||
|
echo "Starting services..."
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && $COMPOSE up -d"
|
||||||
|
|
||||||
|
echo "Waiting for API readiness..."
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && for i in \$(seq 1 30); do status=\$(docker inspect -f '{{.State.Health.Status}}' drivers_bot-api-1 2>/dev/null || echo missing); echo \"api_health=\$status\"; [ \"\$status\" = healthy ] && exit 0; sleep 2; done; $COMPOSE logs --tail=120 api; exit 1"
|
||||||
|
|
||||||
|
echo "Running remote smoke tests..."
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && BASE_URL='$BASE_URL' ./scripts/smoke_test.sh && curl -fsSI '$BASE_URL/admin.html' | head -5 && $COMPOSE ps"
|
||||||
|
|
||||||
|
send_remote_report "✅ CarPass rsync deploy completed
|
||||||
|
Branch: $BRANCH
|
||||||
|
Revision: $REVISION
|
||||||
|
Migration: $(ssh "$REMOTE" "cd '$REMOTE_DIR' && curl -fsS '$BASE_URL/ready'" | tr '\n' ' ')
|
||||||
|
Checks: /health ok, /ready ok, /metrics ok, /admin.html 200
|
||||||
|
Services: api healthy, bot restarted"
|
||||||
|
|
||||||
|
echo "Deploy completed."
|
||||||
81
scripts/send_telegram_report.py
Executable file
81
scripts/send_telegram_report.py
Executable file
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import async_session_factory
|
||||||
|
from app.models import car, expense, gamification, push # noqa: F401
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
REPORT_ROLES = {"admin", "super_admin", "moderator", "support"}
|
||||||
|
|
||||||
|
|
||||||
|
def env_recipients() -> list[str]:
|
||||||
|
recipients: list[str] = []
|
||||||
|
if settings.admin_notification_chat_id:
|
||||||
|
recipients.append(settings.admin_notification_chat_id)
|
||||||
|
recipients.extend(str(item) for item in settings.admin_telegram_id_list)
|
||||||
|
return recipients
|
||||||
|
|
||||||
|
|
||||||
|
async def db_recipients() -> list[str]:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(User.telegram_id).where(User.platform_role.in_(REPORT_ROLES))
|
||||||
|
)
|
||||||
|
return [str(row[0]) for row in result.all() if row[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def unique(values: Iterable[str]) -> list[str]:
|
||||||
|
return list(dict.fromkeys(item.strip() for item in values if item and item.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
async def send_report(text: str, *, dry_run: bool = False) -> int:
|
||||||
|
recipients = unique([*env_recipients(), *(await db_recipients())])
|
||||||
|
if dry_run:
|
||||||
|
print(f"telegram_report_dry_run recipients={len(recipients)}")
|
||||||
|
return len(recipients)
|
||||||
|
if not settings.bot_token or not recipients:
|
||||||
|
print("telegram_report_skipped")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
for chat_id in recipients:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
f"https://api.telegram.org/bot{settings.bot_token}/sendMessage",
|
||||||
|
json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
sent += 1
|
||||||
|
except Exception as exc: # noqa: BLE001 - deploy report must never fail deploy
|
||||||
|
print(f"telegram_report_failed chat_id={chat_id} error={type(exc).__name__}")
|
||||||
|
print(f"telegram_report_sent_count {sent}")
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Send a CarPass operational Telegram report.")
|
||||||
|
parser.add_argument("--text", help="Report text. Defaults to CARPASS_REPORT_TEXT.")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
text = args.text or os.getenv("CARPASS_REPORT_TEXT") or ""
|
||||||
|
if not text.strip():
|
||||||
|
raise SystemExit("Report text is required")
|
||||||
|
await send_report(text, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -13,4 +13,10 @@ echo
|
|||||||
|
|
||||||
echo "Checking metrics..."
|
echo "Checking metrics..."
|
||||||
curl -fsS "$BASE_URL/metrics" | grep -q "carpass_requests_total"
|
curl -fsS "$BASE_URL/metrics" | grep -q "carpass_requests_total"
|
||||||
|
|
||||||
|
for path in / /sto.html /admin.html /work_order.html; do
|
||||||
|
echo "Checking static page $path..."
|
||||||
|
curl -fsSI "$BASE_URL$path" | grep -q "200 OK"
|
||||||
|
done
|
||||||
|
|
||||||
echo "Smoke test passed."
|
echo "Smoke test passed."
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ def configure_settings() -> None:
|
|||||||
settings.internal_api_token = TEST_INTERNAL_TOKEN
|
settings.internal_api_token = TEST_INTERNAL_TOKEN
|
||||||
settings.app_env = "test"
|
settings.app_env = "test"
|
||||||
settings.allow_dev_auth = False
|
settings.allow_dev_auth = False
|
||||||
|
settings.admin_telegram_ids = ""
|
||||||
|
settings.admin_notification_chat_id = ""
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
423
tests/test_admin_control_center.py
Normal file
423
tests/test_admin_control_center.py
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import pytest
|
||||||
|
from conftest import make_init_data
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services import admin_notifications
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_admin(client, internal_headers) -> None:
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 9001, "first_name": "Admin", "platform_role": "admin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_analyst(client, internal_headers) -> dict[str, str]:
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 7001, "first_name": "Analyst", "platform_role": "analyst"},
|
||||||
|
)
|
||||||
|
return {"X-Telegram-Init-Data": make_init_data(7001, "Analyst")}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_user_creates_admin_notification(client, admin_auth_headers, internal_headers) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 123456, "first_name": "Ivan", "username": "ivan"},
|
||||||
|
)
|
||||||
|
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert any(
|
||||||
|
item["event_type"] == "user_registered" and item["idempotency_key"] == "user_registered:123456"
|
||||||
|
for item in notifications.json()["rows"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_notification_idempotency_for_user_registration(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
payload = {"telegram_id": 223344, "first_name": "Repeat"}
|
||||||
|
|
||||||
|
await client.post("/api/users", headers=internal_headers, json=payload)
|
||||||
|
await client.post("/api/users", headers=internal_headers, json=payload)
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
matches = [
|
||||||
|
item
|
||||||
|
for item in notifications.json()["rows"]
|
||||||
|
if item["idempotency_key"] == "user_registered:223344"
|
||||||
|
]
|
||||||
|
assert len(matches) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_telegram_admin_delivery_failure_does_not_break_user_flow(
|
||||||
|
client, admin_auth_headers, internal_headers, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
class BrokenTelegramClient:
|
||||||
|
def __init__(self, *args, **kwargs): # noqa: D107
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def post(self, *args, **kwargs): # noqa: ARG002
|
||||||
|
raise RuntimeError("telegram down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "admin_telegram_ids", "777")
|
||||||
|
monkeypatch.setattr(admin_notifications.httpx, "AsyncClient", BrokenTelegramClient)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 334455, "first_name": "Best Effort"},
|
||||||
|
)
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
created = [
|
||||||
|
item
|
||||||
|
for item in notifications.json()["rows"]
|
||||||
|
if item["idempotency_key"] == "user_registered:334455"
|
||||||
|
][0]
|
||||||
|
assert created["telegram_status"] == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_sto_application_creates_admin_notification(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
center = await client.post(
|
||||||
|
"/api/service-centers",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"display_name": "Auto Master",
|
||||||
|
"country": "KR",
|
||||||
|
"city": "Gwangju",
|
||||||
|
"phone": "+82-10-0000-0000",
|
||||||
|
"document_photo_urls": ["doc-a.jpg", "doc-b.jpg"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert center.status_code == 201
|
||||||
|
assert any(
|
||||||
|
item["event_type"] == "sto_application_created"
|
||||||
|
and item["entity_id"] == str(center.json()["id"])
|
||||||
|
for item in notifications.json()["rows"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_dashboard_requires_admin_role(client, auth_headers, admin_auth_headers, internal_headers) -> None:
|
||||||
|
forbidden = await client.get("/api/admin/dashboard", headers=auth_headers)
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
allowed = await client.get("/api/admin/dashboard", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert allowed.status_code == 200
|
||||||
|
assert "users_total" in allowed.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_explorer_rejects_unknown_source_and_field(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
unknown_source = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "raw_sql", "limit": 25},
|
||||||
|
)
|
||||||
|
forbidden_field = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "users", "limit": 25, "sql": "select * from users"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert unknown_source.status_code == 400
|
||||||
|
assert forbidden_field.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_explorer_masks_sensitive_data_and_applies_limit(
|
||||||
|
client, internal_headers
|
||||||
|
) -> None:
|
||||||
|
analyst_headers = await ensure_analyst(client, internal_headers)
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 889900, "first_name": "Visible", "platform_role": "user"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=analyst_headers,
|
||||||
|
json={"source": "users", "limit": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
rows = response.json()["rows"]
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert isinstance(rows[0]["telegram_id"], str)
|
||||||
|
assert rows[0]["telegram_id"] != "889900"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sensitive_data_requires_admin_and_reason(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
missing_reason = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "users", "include_sensitive": True, "limit": 25},
|
||||||
|
)
|
||||||
|
with_reason = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={
|
||||||
|
"source": "users",
|
||||||
|
"include_sensitive": True,
|
||||||
|
"reason": "support request",
|
||||||
|
"telegram_id": 9001,
|
||||||
|
"limit": 25,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert missing_reason.status_code == 400
|
||||||
|
assert with_reason.status_code == 200
|
||||||
|
assert with_reason.json()["rows"][0]["telegram_id"] == 9001
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_query_creates_audit_log(client, admin_auth_headers, internal_headers) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "users", "limit": 25},
|
||||||
|
)
|
||||||
|
audit = await client.get("/api/admin/audit-log?action=admin.data.query", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert audit.status_code == 200
|
||||||
|
assert any(item["action"] == "admin.data.query" for item in audit.json())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_data_update_requires_reason_and_audits(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
user = (
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 456789, "first_name": "Before"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
missing_reason = await client.patch(
|
||||||
|
f"/api/admin/data/users/{user['id']}",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"values": {"first_name": "After"}, "reason": ""},
|
||||||
|
)
|
||||||
|
updated = await client.patch(
|
||||||
|
f"/api/admin/data/users/{user['id']}",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"values": {"first_name": "After"}, "reason": "support correction"},
|
||||||
|
)
|
||||||
|
audit = await client.get("/api/admin/audit-log?action=admin.data.update", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert missing_reason.status_code == 400
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["row"]["first_name"] == "After"
|
||||||
|
assert any(item["action"] == "admin.data.update" for item in audit.json())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_data_update_rejects_forbidden_field(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
user = (
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 556677, "first_name": "Nope"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/admin/data/users/{user['id']}",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"values": {"telegram_id": 1}, "reason": "support correction"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Forbidden fields" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_data_delete_soft_blocks_user(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
user = (
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 667788, "first_name": "Blocked soon"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
deleted = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/admin/data/users/{user['id']}",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"reason": "support requested block"},
|
||||||
|
)
|
||||||
|
query = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "users", "user_id": user["id"], "limit": 25},
|
||||||
|
)
|
||||||
|
audit = await client.get("/api/admin/audit-log?action=admin.data.delete", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert deleted.status_code == 200
|
||||||
|
assert deleted.json()["mode"] == "soft"
|
||||||
|
assert query.json()["rows"][0]["platform_role"] == "blocked"
|
||||||
|
assert any(item["action"] == "admin.data.delete" for item in audit.json())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_data_delete_hard_deletes_fuel_entry(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Admin delete fuel"})).json()
|
||||||
|
fuel = (
|
||||||
|
await client.post(
|
||||||
|
"/api/fuel",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"car_id": car["id"],
|
||||||
|
"entry_date": "2026-05-18",
|
||||||
|
"odometer": 1200,
|
||||||
|
"liters": 35,
|
||||||
|
"price_per_liter": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
deleted = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/admin/data/fuel_entries/{fuel['id']}",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"reason": "duplicate record cleanup"},
|
||||||
|
)
|
||||||
|
query = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "fuel_entries", "vehicle_id": car["id"], "limit": 25},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert deleted.status_code == 200
|
||||||
|
assert deleted.json()["mode"] == "hard"
|
||||||
|
assert query.json()["rows"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_sto_queue_and_approve_audit(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
center = (
|
||||||
|
await client.post(
|
||||||
|
"/api/service-centers",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"display_name": "Pending Admin Queue", "country": "KR", "city": "Seoul"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
pending = await client.get("/api/admin/sto-applications", headers=admin_auth_headers)
|
||||||
|
approved = await client.post(
|
||||||
|
f"/api/admin/sto-applications/{center['id']}/approve",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"comment": "ok"},
|
||||||
|
)
|
||||||
|
audit = await client.get("/api/admin/audit-log?action=service_center.verify", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert center["id"] in [item["id"] for item in pending.json()["rows"]]
|
||||||
|
assert approved.status_code == 200
|
||||||
|
assert approved.json()["verification_status"] == "approved"
|
||||||
|
assert any(item["action"] == "service_center.verify" for item in audit.json())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blocked_ocr_upload_creates_admin_notification(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/ocr/vin",
|
||||||
|
headers=auth_headers,
|
||||||
|
files={"file": ("invoice.exe", b"not an image", "image/jpeg")},
|
||||||
|
)
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 415
|
||||||
|
assert any(item["event_type"] == "upload_blocked" for item in notifications.json()["rows"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_creates_admin_notification(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
last_response = None
|
||||||
|
for index in range(9):
|
||||||
|
last_response = await client.post(
|
||||||
|
"/api/ocr/vin",
|
||||||
|
headers=auth_headers,
|
||||||
|
files={"file": (f"vin-{index}.txt", b"VIN KMHCT41BAHU123456", "text/plain")},
|
||||||
|
)
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert last_response is not None
|
||||||
|
assert last_response.status_code == 429
|
||||||
|
assert any(item["event_type"] == "rate_limit_exceeded" for item in notifications.json()["rows"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_can_retry_notification_queues(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
response = await client.post("/api/admin/notifications/retry", headers=admin_auth_headers)
|
||||||
|
audit = await client.get("/api/admin/audit-log?action=admin.notifications.retry", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert {"service_delivered", "admin_delivered", "limit"} <= response.json().keys()
|
||||||
|
assert any(item["action"] == "admin.notifications.retry" for item in audit.json())
|
||||||
245
web/admin.html
Normal file
245
web/admin.html
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#16806a" />
|
||||||
|
<title>Admin Control Center</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="auth-required admin-page">
|
||||||
|
<div class="auth-overlay" id="authOverlay">
|
||||||
|
<div class="auth-panel">
|
||||||
|
<p class="eyebrow">CarPass</p>
|
||||||
|
<h1>Админ-панель</h1>
|
||||||
|
<p id="authMessage">Откройте страницу через Telegram, чтобы подтвердить права администратора.</p>
|
||||||
|
<div class="auth-actions">
|
||||||
|
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
|
||||||
|
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="shell admin-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">CarPass Admin</p>
|
||||||
|
<h1>Control Center</h1>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<button class="icon-btn" id="refreshBtn" title="Обновить" aria-label="Обновить">↻</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="passport-panel admin-hero">
|
||||||
|
<div class="passport-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Пилотный контур</p>
|
||||||
|
<h2>Операционный обзор</h2>
|
||||||
|
<small id="adminMeta">Загружаю доступ и источники данных...</small>
|
||||||
|
</div>
|
||||||
|
<span class="trust-badge" id="adminRoleBadge">Проверка</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="admin-tabs" aria-label="Разделы админки">
|
||||||
|
<button type="button" data-admin-tab="dashboard">Dashboard</button>
|
||||||
|
<button type="button" data-admin-tab="notifications">Notifications</button>
|
||||||
|
<button type="button" data-admin-tab="users">Users</button>
|
||||||
|
<button type="button" data-admin-tab="sto">СТО</button>
|
||||||
|
<button type="button" data-admin-tab="sto-applications">Заявки СТО</button>
|
||||||
|
<button type="button" data-admin-tab="vehicles">Авто</button>
|
||||||
|
<button type="button" data-admin-tab="appointments">Записи</button>
|
||||||
|
<button type="button" data-admin-tab="work-orders">Заказ-наряды</button>
|
||||||
|
<button type="button" data-admin-tab="data">Data Explorer</button>
|
||||||
|
<button type="button" data-admin-tab="audit">Audit Log</button>
|
||||||
|
<button type="button" data-admin-tab="exports">Exports</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section id="panel-dashboard" class="admin-panel workspace">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Сервис</p>
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats admin-stats" id="dashboardStats"></div>
|
||||||
|
<div class="admin-grid">
|
||||||
|
<section>
|
||||||
|
<h3>Последние события</h3>
|
||||||
|
<div id="dashboardAlerts" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Быстрые переходы</h3>
|
||||||
|
<div id="quickLinks" class="admin-link-grid"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-notifications" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">События</p>
|
||||||
|
<h2>Admin notifications</h2>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" id="retryNotificationsBtn">Retry</button>
|
||||||
|
<button type="button" id="readAllBtn">Прочитать все</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="notificationsList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-users" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Аккаунты</p>
|
||||||
|
<h2>Users</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="admin-filter" data-list-filter="users">
|
||||||
|
<input name="search" placeholder="Поиск по имени или username" />
|
||||||
|
<button type="submit">Найти</button>
|
||||||
|
</form>
|
||||||
|
<div id="usersTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-sto" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Партнеры</p>
|
||||||
|
<h2>СТО</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="admin-filter" data-list-filter="sto">
|
||||||
|
<input name="city" placeholder="Город" />
|
||||||
|
<select name="status">
|
||||||
|
<option value="">Любой статус</option>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
<option value="approved">approved</option>
|
||||||
|
<option value="needs_changes">needs_changes</option>
|
||||||
|
<option value="rejected">rejected</option>
|
||||||
|
<option value="suspended">suspended</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Фильтр</button>
|
||||||
|
</form>
|
||||||
|
<div id="stoTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-sto-applications" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Модерация</p>
|
||||||
|
<h2>Заявки СТО</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="applicationsList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-vehicles" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Гараж</p>
|
||||||
|
<h2>Автомобили</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="vehiclesTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-appointments" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Календарь</p>
|
||||||
|
<h2>Записи</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="appointmentsTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-work-orders" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Работы</p>
|
||||||
|
<h2>Заказ-наряды</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="workOrdersTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-data" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Без SQL</p>
|
||||||
|
<h2>Data Explorer</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="dataForm" class="grid-form admin-data-form">
|
||||||
|
<label>Тип данных<select name="source" id="sourceSelect"></select></label>
|
||||||
|
<label>Дата от<input name="date_from" type="date" /></label>
|
||||||
|
<label>Дата до<input name="date_to" type="date" /></label>
|
||||||
|
<label>Status<input name="status" /></label>
|
||||||
|
<label>User ID<input name="user_id" type="number" /></label>
|
||||||
|
<label>Telegram ID<input name="telegram_id" type="number" /></label>
|
||||||
|
<label>Vehicle ID<input name="vehicle_id" type="number" /></label>
|
||||||
|
<label>STO ID<input name="sto_id" type="number" /></label>
|
||||||
|
<label>City<input name="city" /></label>
|
||||||
|
<label>Role<input name="role" /></label>
|
||||||
|
<label>Search<input name="search" /></label>
|
||||||
|
<label>Sort
|
||||||
|
<select name="sort" id="sortSelect"></select>
|
||||||
|
</label>
|
||||||
|
<label>Limit
|
||||||
|
<select name="limit">
|
||||||
|
<option>25</option>
|
||||||
|
<option selected>50</option>
|
||||||
|
<option>100</option>
|
||||||
|
<option>500</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="check admin-check"><input name="include_sensitive" type="checkbox" /> Show sensitive</label>
|
||||||
|
<label>Reason<input name="reason" placeholder="Обязательно для sensitive/export" /></label>
|
||||||
|
<div class="admin-form-actions">
|
||||||
|
<button type="submit">Запросить</button>
|
||||||
|
<button type="button" class="ghost-btn" id="exportJsonBtn">JSON export</button>
|
||||||
|
<button type="button" class="ghost-btn" id="exportCsvBtn">CSV export</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="sourceHint" class="admin-source-hint"></div>
|
||||||
|
<div id="dataResult" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-audit" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Контроль</p>
|
||||||
|
<h2>Audit Log</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="auditForm" class="admin-filter">
|
||||||
|
<input name="action" placeholder="Action" />
|
||||||
|
<input name="actor_id" type="number" placeholder="Actor ID" />
|
||||||
|
<input name="entity_type" placeholder="Entity type" />
|
||||||
|
<input name="entity_id" placeholder="Entity ID" />
|
||||||
|
<button type="submit">Показать</button>
|
||||||
|
</form>
|
||||||
|
<div id="auditTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-exports" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Выгрузки</p>
|
||||||
|
<h2>Exports</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="exportsTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
|
||||||
|
<script src="/static/page_common.js"></script>
|
||||||
|
<script src="/static/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
479
web/static/admin.js
Normal file
479
web/static/admin.js
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
const AdminPage = (() => {
|
||||||
|
const { api, boot, toast, escapeHtml, formData, formatDateTime } = CarPassPage;
|
||||||
|
const state = {
|
||||||
|
active: "dashboard",
|
||||||
|
sources: [],
|
||||||
|
sourcesByName: {},
|
||||||
|
sorts: [],
|
||||||
|
lastDataPayload: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const panels = {
|
||||||
|
dashboard: "#panel-dashboard",
|
||||||
|
notifications: "#panel-notifications",
|
||||||
|
users: "#panel-users",
|
||||||
|
sto: "#panel-sto",
|
||||||
|
"sto-applications": "#panel-sto-applications",
|
||||||
|
vehicles: "#panel-vehicles",
|
||||||
|
appointments: "#panel-appointments",
|
||||||
|
"work-orders": "#panel-work-orders",
|
||||||
|
data: "#panel-data",
|
||||||
|
audit: "#panel-audit",
|
||||||
|
exports: "#panel-exports",
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickLinks = [
|
||||||
|
["notifications", "Notifications"],
|
||||||
|
["users", "Users"],
|
||||||
|
["sto-applications", "Заявки СТО"],
|
||||||
|
["vehicles", "Авто"],
|
||||||
|
["data", "Data Explorer"],
|
||||||
|
["audit", "Audit Log"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function qs(selector) {
|
||||||
|
return document.querySelector(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueOrDash(value) {
|
||||||
|
if (value === null || value === undefined || value === "") return "-";
|
||||||
|
if (typeof value === "string" && value.includes("T")) return formatDateTime(value);
|
||||||
|
return escapeHtml(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(section) {
|
||||||
|
state.active = panels[section] ? section : "dashboard";
|
||||||
|
Object.entries(panels).forEach(([name, selector]) => {
|
||||||
|
qs(selector)?.classList.toggle("hidden", name !== state.active);
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||||
|
button.classList.toggle("active", button.dataset.adminTab === state.active);
|
||||||
|
});
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("section", state.active);
|
||||||
|
window.history.replaceState({}, "", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmpty(root, text = "Нет данных") {
|
||||||
|
root.innerHTML = `<div class="tip-card">${escapeHtml(text)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(root, error) {
|
||||||
|
root.innerHTML = `<div class="tip-card error-state">${escapeHtml(error.message || "Ошибка")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceConfig(source) {
|
||||||
|
return source ? state.sourcesByName[source] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editableValues(row, source) {
|
||||||
|
const config = sourceConfig(source);
|
||||||
|
const fields = config?.editable || [];
|
||||||
|
return fields.reduce((payload, field) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(row, field)) payload[field] = row[field];
|
||||||
|
return payload;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSourceHint(source) {
|
||||||
|
const hint = qs("#sourceHint");
|
||||||
|
if (!hint) return;
|
||||||
|
const config = sourceConfig(source);
|
||||||
|
if (!config) {
|
||||||
|
hint.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const editable = config.editable?.length ? config.editable.join(", ") : "нет";
|
||||||
|
const deleteMode = config.deletable ? config.delete_mode : "нет";
|
||||||
|
hint.textContent = `Редактируемые поля: ${editable}. Удаление: ${deleteMode}. Все изменения требуют reason и пишутся в Audit Log.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutateRow(action, source, row) {
|
||||||
|
const config = sourceConfig(source);
|
||||||
|
if (!config || !row?.id) return;
|
||||||
|
if (action === "edit") {
|
||||||
|
const draft = editableValues(row, source);
|
||||||
|
const raw = window.prompt("JSON с изменяемыми полями", JSON.stringify(draft, null, 2));
|
||||||
|
if (!raw) return;
|
||||||
|
let values;
|
||||||
|
try {
|
||||||
|
values = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
toast("Некорректный JSON", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reason = window.prompt("Причина изменения") || "";
|
||||||
|
if (!reason.trim()) return;
|
||||||
|
await api(`/admin/data/${source}/${row.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ values, reason }),
|
||||||
|
});
|
||||||
|
toast("Запись обновлена");
|
||||||
|
} else {
|
||||||
|
const reason = window.prompt(`Причина удаления ${source} #${row.id}`) || "";
|
||||||
|
if (!reason.trim()) return;
|
||||||
|
if (!window.confirm(`Удалить ${source} #${row.id}?`)) return;
|
||||||
|
await api(`/admin/data/${source}/${row.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
toast(config.delete_mode === "hard" ? "Запись удалена" : "Запись скрыта/отключена");
|
||||||
|
}
|
||||||
|
await loadActiveSection();
|
||||||
|
if (state.active === "data" && state.lastDataPayload) {
|
||||||
|
await submitDataQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTableActions(root, source, rows) {
|
||||||
|
root.querySelectorAll("[data-admin-row-action]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const row = rows[Number(button.dataset.rowIndex)];
|
||||||
|
try {
|
||||||
|
await mutateRow(button.dataset.adminRowAction, source, row);
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message || "Ошибка", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(root, rows, preferredColumns = [], source = null) {
|
||||||
|
if (!rows?.length) {
|
||||||
|
renderEmpty(root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const columns = preferredColumns.length ? preferredColumns : Object.keys(rows[0]);
|
||||||
|
const config = sourceConfig(source);
|
||||||
|
const hasActions = Boolean(config && rows.some((row) => row.id) && (config.editable?.length || config.deletable));
|
||||||
|
root.innerHTML = `
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}
|
||||||
|
${hasActions ? "<th class=\"admin-actions-head\">Действия</th>" : ""}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows
|
||||||
|
.map(
|
||||||
|
(row) => `
|
||||||
|
<tr>
|
||||||
|
${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>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
if (hasActions) bindTableActions(root, source, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(value) {
|
||||||
|
return `<span class="admin-badge">${escapeHtml(value || "-")}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
const data = await api("/admin/dashboard");
|
||||||
|
const statLabels = [
|
||||||
|
["users_today", "Новые сегодня"],
|
||||||
|
["users_7d", "Новые 7 дней"],
|
||||||
|
["users_total", "Всего пользователей"],
|
||||||
|
["active_users", "Активные"],
|
||||||
|
["vehicles_total", "Авто"],
|
||||||
|
["pending_sto_applications", "Pending СТО"],
|
||||||
|
["approved_sto", "Approved СТО"],
|
||||||
|
["appointments_today", "Записи сегодня"],
|
||||||
|
["active_work_orders", "Активные ЗН"],
|
||||||
|
["completed_work_orders", "Завершенные ЗН"],
|
||||||
|
["system_errors", "Ошибки"],
|
||||||
|
["security_events", "Security"],
|
||||||
|
];
|
||||||
|
qs("#dashboardStats").innerHTML = statLabels
|
||||||
|
.map(([key, label]) => `<div class="stat"><span>${label}</span><strong>${data[key] ?? 0}</strong></div>`)
|
||||||
|
.join("");
|
||||||
|
const alerts = qs("#dashboardAlerts");
|
||||||
|
alerts.innerHTML = data.latest_alerts?.length
|
||||||
|
? data.latest_alerts
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<article class="stack-item">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(item.title)}</strong>
|
||||||
|
<small>${badge(item.event_type)} ${formatDateTime(item.created_at)}</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<div class="tip-card">Критичных событий нет</div>`;
|
||||||
|
qs("#quickLinks").innerHTML = quickLinks
|
||||||
|
.map(([section, label]) => `<button type="button" data-admin-tab="${section}">${label}</button>`)
|
||||||
|
.join("");
|
||||||
|
bindTabButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications() {
|
||||||
|
const root = qs("#notificationsList");
|
||||||
|
try {
|
||||||
|
const data = await api("/admin/notifications?limit=100");
|
||||||
|
if (!data.rows.length) return renderEmpty(root);
|
||||||
|
root.innerHTML = data.rows
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<article class="stack-item">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(item.title)}</strong>
|
||||||
|
<small>${badge(item.event_type)} ${badge(item.severity)} ${badge(item.status)} ${formatDateTime(item.created_at)}</small>
|
||||||
|
<p>${escapeHtml(item.body || "")}</p>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" data-read-notification="${item.id}">Read</button>
|
||||||
|
<button type="button" class="ghost-btn" data-dismiss-notification="${item.id}">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
root.querySelectorAll("[data-read-notification]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
await api(`/admin/notifications/${button.dataset.readNotification}/read`, { method: "POST" });
|
||||||
|
await loadNotifications();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
root.querySelectorAll("[data-dismiss-notification]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
await api(`/admin/notifications/${button.dataset.dismissNotification}/dismiss`, { method: "POST" });
|
||||||
|
await loadNotifications();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
renderError(root, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers(search = "") {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (search) query.set("search", search);
|
||||||
|
const data = await api(`/admin/users?${query.toString()}`);
|
||||||
|
renderTable(qs("#usersTable"), data.rows, ["id", "telegram_id", "username", "first_name", "platform_role", "created_at"], "users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSto(filters = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value) query.set(key, value);
|
||||||
|
});
|
||||||
|
const data = await api(`/admin/sto?${query.toString()}`);
|
||||||
|
renderTable(qs("#stoTable"), data.rows, ["id", "display_name", "city", "phone", "verification_status", "owner_user_id", "created_at"], "sto_profiles");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApplications() {
|
||||||
|
const root = qs("#applicationsList");
|
||||||
|
try {
|
||||||
|
const data = await api("/admin/sto-applications");
|
||||||
|
if (!data.rows.length) return renderEmpty(root, "Очередь модерации пуста");
|
||||||
|
root.innerHTML = data.rows
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<article class="stack-item">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(item.display_name || item.legal_name || `СТО #${item.id}`)}</strong>
|
||||||
|
<small>${badge(item.verification_status)} ${escapeHtml(item.city || "-")} ${formatDateTime(item.created_at)}</small>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" data-application-action="approve" data-application-id="${item.id}">Approve</button>
|
||||||
|
<button type="button" class="ghost-btn" data-application-action="request-changes" data-application-id="${item.id}">Правки</button>
|
||||||
|
<button type="button" class="danger-btn" data-application-action="reject" data-application-id="${item.id}">Reject</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
root.querySelectorAll("[data-application-action]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const action = button.dataset.applicationAction;
|
||||||
|
const reason = action === "approve" ? "Approved in admin panel" : window.prompt("Причина") || "";
|
||||||
|
if (action !== "approve" && !reason) return;
|
||||||
|
await api(`/admin/sto-applications/${button.dataset.applicationId}/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ reason, comment: reason }),
|
||||||
|
});
|
||||||
|
toast("Статус заявки обновлен");
|
||||||
|
await loadApplications();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
renderError(root, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSourceTable(source, rootSelector, columns) {
|
||||||
|
const root = qs(rootSelector);
|
||||||
|
try {
|
||||||
|
const data = await api("/admin/data/query", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ source, limit: 100 }),
|
||||||
|
});
|
||||||
|
renderTable(root, data.rows, columns, source);
|
||||||
|
} catch (error) {
|
||||||
|
renderError(root, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPayload(payload) {
|
||||||
|
const cleaned = {};
|
||||||
|
Object.entries(payload).forEach(([key, value]) => {
|
||||||
|
if (value === "" || value === null || value === undefined) return;
|
||||||
|
if (["user_id", "telegram_id", "vehicle_id", "sto_id", "limit"].includes(key)) {
|
||||||
|
cleaned[key] = Number(value);
|
||||||
|
} else if (key === "include_sensitive") {
|
||||||
|
cleaned[key] = value === "on";
|
||||||
|
} else {
|
||||||
|
cleaned[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!("include_sensitive" in cleaned)) cleaned.include_sensitive = false;
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDataQuery(format = null) {
|
||||||
|
const payload = cleanPayload(formData(qs("#dataForm")));
|
||||||
|
state.lastDataPayload = payload;
|
||||||
|
renderSourceHint(payload.source);
|
||||||
|
if (format) {
|
||||||
|
const result = await api("/admin/data/export", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ...payload, export_format: format }),
|
||||||
|
});
|
||||||
|
toast(`Export #${result.id} готов`);
|
||||||
|
await loadExports();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await api("/admin/data/query", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
renderTable(qs("#dataResult"), data.rows, [], payload.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudit(params = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value) query.set(key, value);
|
||||||
|
});
|
||||||
|
const rows = await api(`/admin/audit-log?${query.toString()}`);
|
||||||
|
renderTable(qs("#auditTable"), rows, ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "created_at"], "audit_logs");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExports() {
|
||||||
|
const data = await api("/admin/exports");
|
||||||
|
renderTable(qs("#exportsTable"), data.rows, ["id", "source", "export_format", "status", "row_count", "reason", "expires_at", "created_at"], "imports_exports");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveSection() {
|
||||||
|
if (state.active === "dashboard") return loadDashboard();
|
||||||
|
if (state.active === "notifications") return loadNotifications();
|
||||||
|
if (state.active === "users") return loadUsers();
|
||||||
|
if (state.active === "sto") return loadSto();
|
||||||
|
if (state.active === "sto-applications") return loadApplications();
|
||||||
|
if (state.active === "vehicles") return loadSourceTable("vehicles", "#vehiclesTable", ["id", "owner_id", "name", "make", "model", "year", "vin", "plate_number", "current_odometer", "created_at"]);
|
||||||
|
if (state.active === "appointments") return loadSourceTable("appointments", "#appointmentsTable", ["id", "service_center_id", "vehicle_id", "owner_user_id", "service_type", "status", "requested_start_at", "created_at"]);
|
||||||
|
if (state.active === "work-orders") return loadSourceTable("work_orders", "#workOrdersTable", ["id", "service_center_id", "vehicle_id", "owner_user_id", "status", "final_total", "currency", "completed_at"]);
|
||||||
|
if (state.active === "audit") return loadAudit();
|
||||||
|
if (state.active === "exports") return loadExports();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTabButtons() {
|
||||||
|
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
setActive(button.dataset.adminTab);
|
||||||
|
try {
|
||||||
|
await loadActiveSection();
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message || "Ошибка", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindForms() {
|
||||||
|
qs("#refreshBtn")?.addEventListener("click", () => loadActiveSection().catch((error) => toast(error.message, "error")));
|
||||||
|
qs("#readAllBtn")?.addEventListener("click", async () => {
|
||||||
|
await api("/admin/notifications/read-all", { method: "POST" });
|
||||||
|
await loadNotifications();
|
||||||
|
});
|
||||||
|
qs("#retryNotificationsBtn")?.addEventListener("click", async () => {
|
||||||
|
const result = await api("/admin/notifications/retry", { method: "POST" });
|
||||||
|
toast(`Retry: service ${result.service_delivered}, admin ${result.admin_delivered}`);
|
||||||
|
await loadNotifications();
|
||||||
|
});
|
||||||
|
document.querySelector("[data-list-filter='users']")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await loadUsers(formData(event.currentTarget).search || "");
|
||||||
|
});
|
||||||
|
document.querySelector("[data-list-filter='sto']")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await loadSto(formData(event.currentTarget));
|
||||||
|
});
|
||||||
|
qs("#dataForm")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await submitDataQuery().catch((error) => toast(error.message, "error"));
|
||||||
|
});
|
||||||
|
qs("#exportJsonBtn")?.addEventListener("click", () => submitDataQuery("json").catch((error) => toast(error.message, "error")));
|
||||||
|
qs("#exportCsvBtn")?.addEventListener("click", () => submitDataQuery("csv").catch((error) => toast(error.message, "error")));
|
||||||
|
qs("#auditForm")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await loadAudit(cleanPayload(formData(event.currentTarget)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSources() {
|
||||||
|
const data = await api("/admin/data/sources");
|
||||||
|
state.sources = data.sources || [];
|
||||||
|
state.sourcesByName = Object.fromEntries(state.sources.map((source) => [source.name, source]));
|
||||||
|
state.sorts = data.sorts || [];
|
||||||
|
qs("#sourceSelect").innerHTML = state.sources
|
||||||
|
.filter((source) => source.available && source.allowed)
|
||||||
|
.map((source) => `<option value="${source.name}">${source.name}</option>`)
|
||||||
|
.join("");
|
||||||
|
qs("#sortSelect").innerHTML = state.sorts
|
||||||
|
.map((sort) => `<option value="${sort}">${sort}</option>`)
|
||||||
|
.join("");
|
||||||
|
qs("#sourceSelect")?.addEventListener("change", (event) => renderSourceHint(event.target.value));
|
||||||
|
renderSourceHint(qs("#sourceSelect")?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
qs("#adminRoleBadge").textContent = CarPassPage.state.user?.platform_role || "admin";
|
||||||
|
qs("#adminMeta").textContent = `User #${CarPassPage.state.user?.id || "-"} · Telegram ${CarPassPage.state.user?.telegram_id || "-"}`;
|
||||||
|
await initSources();
|
||||||
|
bindTabButtons();
|
||||||
|
bindForms();
|
||||||
|
const urlSection = new URLSearchParams(window.location.search).get("section");
|
||||||
|
setActive(urlSection || "dashboard");
|
||||||
|
await loadActiveSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
||||||
|
|
||||||
|
CarPassPage.boot(AdminPage.init);
|
||||||
@@ -2069,6 +2069,227 @@ select {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-page {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, #ffffff 0, #edf5f2 220px),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
width: min(1320px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 8;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
background: rgba(238, 243, 241, 0.92);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs button,
|
||||||
|
.admin-link-grid button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs button.active {
|
||||||
|
border-color: rgba(22, 128, 106, 0.48);
|
||||||
|
background: #dff1eb;
|
||||||
|
color: #0e5d4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats {
|
||||||
|
grid-template-columns: repeat(6, minmax(130px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-filter {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-filter button {
|
||||||
|
min-width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 10px 30px rgba(17, 36, 30, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 720px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #f5faf7;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr {
|
||||||
|
transition: background 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover {
|
||||||
|
background: #f7fbf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions-head,
|
||||||
|
.admin-action-cell {
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
min-width: 128px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: -10px 0 18px rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover .admin-action-cell {
|
||||||
|
background: #f7fbf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-action-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-btn {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 22px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid rgba(22, 128, 106, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eef7f3;
|
||||||
|
color: #0e5d4b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-form {
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-check {
|
||||||
|
min-height: 42px;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-source-hint {
|
||||||
|
margin: -2px 0 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(22, 128, 106, 0.16);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f2faf6;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
color: var(--danger);
|
||||||
|
background: #fff4f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.admin-stats {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats .stat {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid,
|
||||||
|
.admin-link-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.work-order-total strong {
|
.work-order-total strong {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: clamp(24px, 4vw, 34px);
|
font-size: clamp(24px, 4vw, 34px);
|
||||||
|
|||||||
Reference in New Issue
Block a user