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
|
||||
ADMIN_TELEGRAM_IDS=
|
||||
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 и не считается критическим каналом пилота.
|
||||
|
||||
## Администрирование
|
||||
|
||||
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, номер, пробег и технические параметры, проходят подтверждение владельца.
|
||||
|
||||
@@ -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.models.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess
|
||||
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
|
||||
|
||||
|
||||
@@ -36,6 +37,24 @@ async def get_or_create_telegram_user(
|
||||
if user is None:
|
||||
user = User(**{key: value for key, value in payload.items() if value is not None})
|
||||
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:
|
||||
for field, value in payload.items():
|
||||
if value is not None:
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.schemas.service_center import (
|
||||
VehicleUpdate,
|
||||
)
|
||||
from app.schemas.user import UserRead
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.odometer import (
|
||||
add_odometer_history,
|
||||
recalculate_current_odometer,
|
||||
@@ -381,6 +382,20 @@ async def create_vehicle(
|
||||
changed_by=current_user.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.refresh(car)
|
||||
return car
|
||||
|
||||
142
app/api/ocr.py
142
app/api/ocr.py
@@ -1,14 +1,16 @@
|
||||
import re
|
||||
import time
|
||||
from datetime import date
|
||||
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
from app.db.session import get_session
|
||||
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.rate_limit import check_rate_limit
|
||||
from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
|
||||
@@ -40,6 +42,72 @@ class OCRResultRead(BaseModel):
|
||||
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)
|
||||
async def parse_text_receipt(
|
||||
request: Request,
|
||||
@@ -49,17 +117,23 @@ async def parse_text_receipt(
|
||||
) -> ReceiptSuggestion:
|
||||
await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session)
|
||||
content = await file.read()
|
||||
validate_upload(
|
||||
content=content,
|
||||
await validate_ocr_upload(
|
||||
session=session,
|
||||
current_user=current_user,
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
max_bytes=MAX_OCR_FILE_BYTES,
|
||||
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
|
||||
content=content,
|
||||
)
|
||||
content_type = (file.content_type or "").lower()
|
||||
if content_type.startswith("image/") or content_type == "application/pdf":
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
if not result.recognized_text:
|
||||
result = await recognize_with_alert(
|
||||
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(
|
||||
confidence=0,
|
||||
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
|
||||
@@ -133,8 +207,22 @@ async def recognize_license_plate(
|
||||
) -> OCRResultRead:
|
||||
await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||
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)
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
await validate_ocr_upload(
|
||||
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(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
||||
@@ -151,8 +239,22 @@ async def recognize_vin(
|
||||
) -> OCRResultRead:
|
||||
await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||
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)
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
await validate_ocr_upload(
|
||||
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(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
||||
@@ -169,8 +271,22 @@ async def recognize_service_document(
|
||||
) -> OCRResultRead:
|
||||
await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||
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)
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
await validate_ocr_upload(
|
||||
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(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
||||
|
||||
@@ -48,6 +48,7 @@ from app.schemas.service_center import (
|
||||
VehicleSearchRequest,
|
||||
VehicleSearchResult,
|
||||
)
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.notifications import notify_platform_moderators
|
||||
from app.services.odometer import validate_odometer_change
|
||||
from app.services.rate_limit import check_rate_limit
|
||||
@@ -147,6 +148,25 @@ async def create_service_center(
|
||||
target_type="service_center",
|
||||
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.refresh(center)
|
||||
await notify_platform_moderators(
|
||||
|
||||
@@ -24,6 +24,11 @@ class Settings(BaseSettings):
|
||||
ocr_languages: str = "eng+rus+kor"
|
||||
admin_telegram_ids: 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")
|
||||
|
||||
|
||||
26
app/main.py
26
app/main.py
@@ -25,7 +25,8 @@ from app.api import (
|
||||
work_orders,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -49,8 +50,29 @@ async def production_headers_and_metrics(request: Request, call_next):
|
||||
start = monotonic()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
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
|
||||
duration = monotonic() - start
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
__tablename__ = "service_work_items"
|
||||
|
||||
@@ -625,3 +648,19 @@ class AuditLog(Base):
|
||||
user_agent: Mapped[str | None] = mapped_column(String(256))
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON)
|
||||
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:
|
||||
allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds)
|
||||
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)
|
||||
return
|
||||
|
||||
@@ -52,7 +58,13 @@ async def check_rate_limit(
|
||||
while bucket and now - bucket[0] > window_seconds:
|
||||
bucket.popleft()
|
||||
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)
|
||||
for identifier in identifiers:
|
||||
_buckets[(scope, identifier)].append(now)
|
||||
@@ -107,18 +119,82 @@ async def log_rate_limit_event(
|
||||
*,
|
||||
scope: str,
|
||||
identifier: str,
|
||||
user: User | None = None,
|
||||
request: Request | None = None,
|
||||
) -> None:
|
||||
if session is None:
|
||||
return
|
||||
from app.models.car import AuditLog
|
||||
client_host = request.client.host if request and request.client else None
|
||||
user_agent = request.headers.get("user-agent") if request else None
|
||||
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(
|
||||
AuditLog(
|
||||
actor_user_id=None,
|
||||
actor_role="system",
|
||||
action="rate_limit.exceeded",
|
||||
target_type=scope,
|
||||
target_id=identifier[:80],
|
||||
metadata_json={"scope": scope, "identifier": identifier},
|
||||
)
|
||||
if session is None:
|
||||
from app.db.session import async_session_factory
|
||||
|
||||
async with async_session_factory() as event_session:
|
||||
await persist_rate_limit_event(
|
||||
event_session,
|
||||
scope=scope,
|
||||
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:
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.api_base_url.rstrip("/")
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
timeout=httpx.Timeout(15.0, connect=5.0),
|
||||
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None:
|
||||
await self._client.aclose()
|
||||
|
||||
def headers(self, telegram_id: int | None = None) -> dict[str, str]:
|
||||
headers = {"X-Internal-API-Token": settings.internal_api_token}
|
||||
@@ -24,18 +39,17 @@ class ApiClient:
|
||||
json: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=15) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
path,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=self.headers(telegram_id),
|
||||
)
|
||||
response.raise_for_status()
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
return response.json()
|
||||
response = await self.client.request(
|
||||
method,
|
||||
path,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=self.headers(telegram_id),
|
||||
)
|
||||
response.raise_for_status()
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
async def upsert_user(self, telegram_user: Any) -> dict[str, Any]:
|
||||
payload = {
|
||||
@@ -44,34 +58,30 @@ class ApiClient:
|
||||
"first_name": telegram_user.first_name,
|
||||
"last_name": telegram_user.last_name,
|
||||
}
|
||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
|
||||
response = await client.post("/api/users", json=payload, headers=self.headers())
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
response = await self.client.post("/api/users", json=payload, headers=self.headers())
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def list_cars(self, owner_id: int, telegram_id: int) -> list[dict[str, Any]]:
|
||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
|
||||
response = await client.get(
|
||||
"/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id)
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
response = await self.client.get(
|
||||
"/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id)
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def create_car(self, owner_id: int, name: str, telegram_id: int) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
|
||||
response = await client.post(
|
||||
"/api/cars",
|
||||
json={"owner_id": owner_id, "name": name},
|
||||
headers=self.headers(telegram_id),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
response = await self.client.post(
|
||||
"/api/cars",
|
||||
json={"owner_id": owner_id, "name": name},
|
||||
headers=self.headers(telegram_id),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def stats(self, car_id: int, telegram_id: int) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
|
||||
response = await client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
response = await self.client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def create_fuel(self, telegram_id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return await self.request("POST", "/api/fuel", telegram_id=telegram_id, json=payload)
|
||||
@@ -126,6 +136,15 @@ class ApiClient:
|
||||
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)
|
||||
|
||||
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(
|
||||
self,
|
||||
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_pending_sto"))
|
||||
async def admin_sto_pending(message: Message) -> None:
|
||||
await upsert(message)
|
||||
try:
|
||||
@@ -459,6 +460,77 @@ async def admin_sto_pending(message: Message) -> None:
|
||||
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:
|
||||
args = (command.args or "").split(maxsplit=1)
|
||||
if not args:
|
||||
@@ -577,7 +649,14 @@ async def admin_callback(callback: CallbackQuery) -> None:
|
||||
@dp.message(F.text == "Помощь")
|
||||
@dp.message(Command("help"))
|
||||
async def help_message(message: Message) -> None:
|
||||
user = await api.upsert_user(message.from_user)
|
||||
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_bookings или /sto_workplace — панель подтвержденного СТО;\n"
|
||||
"• /accept_sto_invite <token> — принять приглашение сотрудника;\n"
|
||||
@@ -601,6 +680,7 @@ async def help_message(message: Message) -> None:
|
||||
"• /sto — каталог проверенных СТО;\n"
|
||||
"• /appointments — мои записи в СТО;\n"
|
||||
f"{sto_workplace_help}"
|
||||
f"{admin_help}"
|
||||
"\n"
|
||||
"Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n"
|
||||
f"{sto_business_help}"
|
||||
@@ -702,7 +782,11 @@ async def main() -> None:
|
||||
raise RuntimeError("INTERNAL_API_TOKEN is empty")
|
||||
settings.validate_webapp_url_for_telegram()
|
||||
bot = Bot(settings.bot_token)
|
||||
await dp.start_polling(bot)
|
||||
try:
|
||||
await dp.start_polling(bot)
|
||||
finally:
|
||||
await api.close()
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
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..."
|
||||
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."
|
||||
|
||||
@@ -40,6 +40,8 @@ def configure_settings() -> None:
|
||||
settings.internal_api_token = TEST_INTERNAL_TOKEN
|
||||
settings.app_env = "test"
|
||||
settings.allow_dev_auth = False
|
||||
settings.admin_telegram_ids = ""
|
||||
settings.admin_notification_chat_id = ""
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: #fff;
|
||||
font-size: clamp(24px, 4vw, 34px);
|
||||
|
||||
Reference in New Issue
Block a user