Compare commits
9 Commits
f4be38f9b9
...
admin-data
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bc9aa6a1 | ||
|
|
58ff6ff614 | ||
| 5e5582664a | |||
|
|
8982299e71 | ||
|
|
59bc6ebd4f | ||
|
|
22b9b40d78 | ||
|
|
2d5695fdce | ||
|
|
0f6d6e31e1 | ||
|
|
fa703acce1 |
@@ -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
|
||||
|
||||
201
ADMIN.md
Normal file
201
ADMIN.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 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.
|
||||
|
||||
Поддержанные события:
|
||||
|
||||
- новый пользователь;
|
||||
- первое авто пользователя;
|
||||
- первая запись пользователя;
|
||||
- новая заявка СТО;
|
||||
- обновление документов или повторная отправка заявки СТО;
|
||||
- изменение статуса заявки СТО;
|
||||
- одобрение, блокировка и разблокировка СТО;
|
||||
- новая запись в СТО, отмена записи СТО;
|
||||
- создание, завершение и отклонение заказ-наряда;
|
||||
- запрос и решение коррекции заказ-наряда;
|
||||
- низкая оценка СТО;
|
||||
- OCR/upload/rate-limit события;
|
||||
- security/system события через общий admin notification service.
|
||||
|
||||
Idempotency key защищает от дублей. Telegram-сообщение содержит кнопку открытия соответствующего раздела админки.
|
||||
|
||||
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`
|
||||
- `ocr_results`
|
||||
- `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")
|
||||
52
alembic/versions/202605190001_ocr_results.py
Normal file
52
alembic/versions/202605190001_ocr_results.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""persist ocr preview results for admin explorer
|
||||
|
||||
Revision ID: 202605190001
|
||||
Revises: 202605170001
|
||||
Create Date: 2026-05-19 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "202605190001"
|
||||
down_revision: str | None = "202605170001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ocr_results",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("vehicle_id", sa.Integer(), nullable=True),
|
||||
sa.Column("scope", sa.String(length=80), nullable=False),
|
||||
sa.Column("filename", sa.String(length=255), nullable=True),
|
||||
sa.Column("content_type", sa.String(length=120), nullable=True),
|
||||
sa.Column("status", sa.String(length=24), server_default="preview", nullable=False),
|
||||
sa.Column("provider", sa.String(length=80), nullable=True),
|
||||
sa.Column("confidence", sa.Numeric(5, 4), nullable=True),
|
||||
sa.Column("recognized_text", sa.Text(), nullable=True),
|
||||
sa.Column("candidates_json", sa.JSON(), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_ocr_results_created_at", "ocr_results", ["created_at"])
|
||||
op.create_index("ix_ocr_results_scope", "ocr_results", ["scope"])
|
||||
op.create_index("ix_ocr_results_status", "ocr_results", ["status"])
|
||||
op.create_index("ix_ocr_results_user_id", "ocr_results", ["user_id"])
|
||||
op.create_index("ix_ocr_results_vehicle_id", "ocr_results", ["vehicle_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ocr_results_vehicle_id", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_user_id", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_status", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_scope", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_created_at", table_name="ocr_results")
|
||||
op.drop_table("ocr_results")
|
||||
1143
app/api/admin.py
1143
app/api/admin.py
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
@@ -7,6 +7,7 @@ from app.db.session import get_session
|
||||
from app.models.car import Car, VehicleAccess
|
||||
from app.models.user import User
|
||||
from app.schemas.car import CarCreate, CarRead, CarUpdate
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.odometer import add_odometer_history, validate_odometer_change
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
@@ -42,6 +43,28 @@ async def create_car(
|
||||
source_record_id=None,
|
||||
changed_by=current_user.id,
|
||||
)
|
||||
vehicle_count = int(
|
||||
(await session.execute(select(func.count(Car.id)).where(Car.owner_id == current_user.id))).scalar_one()
|
||||
or 0
|
||||
)
|
||||
if vehicle_count == 1:
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="vehicle_created",
|
||||
title="Пользователь впервые добавил авто",
|
||||
body="\n".join(
|
||||
[
|
||||
f"User ID: {current_user.id}",
|
||||
f"Telegram ID: {current_user.telegram_id}",
|
||||
f"Авто: {car.name}",
|
||||
f"Пробег: {car.current_odometer or '-'}",
|
||||
]
|
||||
),
|
||||
entity_type="vehicle",
|
||||
entity_id=car.id,
|
||||
idempotency_key=f"vehicle_created:{current_user.id}",
|
||||
metadata={"user_id": current_user.id, "vehicle_id": car.id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(car)
|
||||
return car
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -3,7 +3,7 @@ from io import BytesIO
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
@@ -25,6 +25,7 @@ from app.schemas.expense import (
|
||||
ServiceEntryRead,
|
||||
ServiceEntryUpdate,
|
||||
)
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
|
||||
from app.services.odometer import (
|
||||
apply_odometer_from_record,
|
||||
@@ -53,6 +54,59 @@ async def ensure_entry_owner(
|
||||
return entry
|
||||
|
||||
|
||||
async def maybe_notify_first_record(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user: User,
|
||||
car: Car,
|
||||
record_type: str,
|
||||
record_id: int,
|
||||
) -> None:
|
||||
fuel_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(FuelEntry.id)).join(Car, FuelEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||
)
|
||||
).scalar_one()
|
||||
or 0
|
||||
)
|
||||
service_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(ServiceEntry.id)).join(Car, ServiceEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||
)
|
||||
).scalar_one()
|
||||
or 0
|
||||
)
|
||||
expense_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(ExpenseEntry.id)).join(Car, ExpenseEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||
)
|
||||
).scalar_one()
|
||||
or 0
|
||||
)
|
||||
if fuel_count + service_count + expense_count != 1:
|
||||
return
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="first_record_created",
|
||||
title="Пользователь впервые создал запись",
|
||||
body="\n".join(
|
||||
[
|
||||
f"User ID: {user.id}",
|
||||
f"Telegram ID: {user.telegram_id}",
|
||||
f"Авто: {car.name}",
|
||||
f"Тип записи: {record_type}",
|
||||
]
|
||||
),
|
||||
entity_type="vehicle",
|
||||
entity_id=car.id,
|
||||
idempotency_key=f"first_record_created:{user.id}",
|
||||
metadata={"user_id": user.id, "vehicle_id": car.id, "record_type": record_type, "record_id": record_id},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_fuel_entry(
|
||||
payload: FuelEntryCreate,
|
||||
@@ -78,6 +132,7 @@ async def create_fuel_entry(
|
||||
changed_by=current_user.id,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await maybe_notify_first_record(session, user=current_user, car=car, record_type="fuel", record_id=entry.id)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
@@ -174,6 +229,7 @@ async def create_service_entry(
|
||||
changed_by=current_user.id,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await maybe_notify_first_record(session, user=current_user, car=car, record_type="service", record_id=entry.id)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
@@ -266,6 +322,7 @@ async def create_expense_entry(
|
||||
changed_by=current_user.id,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await maybe_notify_first_record(session, user=current_user, car=car, record_type="expense", record_id=entry.id)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
@@ -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
|
||||
|
||||
270
app/api/ocr.py
270
app/api/ocr.py
@@ -1,15 +1,18 @@
|
||||
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.car import OCRResult
|
||||
from app.models.user import User
|
||||
from app.services.ocr_provider import get_ocr_provider
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.ocr_provider import OcrResult, 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 +43,135 @@ class OCRResultRead(BaseModel):
|
||||
provider: str = "heuristic"
|
||||
|
||||
|
||||
def ocr_candidates_json(result: OcrResult | None) -> list[dict] | None:
|
||||
if result is None:
|
||||
return None
|
||||
return [
|
||||
{"type": candidate.type, "value": candidate.value, "confidence": candidate.confidence}
|
||||
for candidate in result.candidates
|
||||
]
|
||||
|
||||
|
||||
def ocr_confidence(result: OcrResult | None) -> Decimal | None:
|
||||
if result is None or not result.candidates:
|
||||
return None
|
||||
return Decimal(str(round(max(candidate.confidence for candidate in result.candidates), 4)))
|
||||
|
||||
|
||||
async def save_ocr_result(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
scope: str,
|
||||
filename: str | None,
|
||||
content_type: str | None,
|
||||
status: str,
|
||||
result: OcrResult | None = None,
|
||||
recognized_text: str | None = None,
|
||||
provider: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> OCRResult:
|
||||
record = OCRResult(
|
||||
user_id=current_user.id,
|
||||
scope=scope,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
status=status,
|
||||
provider=result.provider if result is not None else provider,
|
||||
confidence=ocr_confidence(result),
|
||||
recognized_text=result.recognized_text if result is not None else recognized_text,
|
||||
candidates_json=ocr_candidates_json(result),
|
||||
error=error,
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
|
||||
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 save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="upload_validation",
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
status="blocked",
|
||||
error=str(exc.detail),
|
||||
)
|
||||
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 save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope=scope,
|
||||
filename=filename,
|
||||
content_type=None,
|
||||
status="failed",
|
||||
error=type(exc).__name__,
|
||||
)
|
||||
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,21 +181,48 @@ 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:
|
||||
if result is not None:
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="parse_text_receipt",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return ReceiptSuggestion(
|
||||
confidence=0,
|
||||
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
|
||||
)
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="parse_text_receipt",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return parse_receipt_text(result.recognized_text)
|
||||
text = " ".join(
|
||||
[
|
||||
@@ -71,6 +230,17 @@ async def parse_text_receipt(
|
||||
content.decode("utf-8", errors="ignore"),
|
||||
]
|
||||
)
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="parse_text_receipt",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
recognized_text=text,
|
||||
provider="text",
|
||||
)
|
||||
await session.commit()
|
||||
return parse_receipt_text(text)
|
||||
|
||||
|
||||
@@ -133,8 +303,32 @@ 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")
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="license_plate",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
||||
@@ -151,8 +345,32 @@ 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")
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="vin",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
||||
@@ -169,8 +387,32 @@ 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")
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="service_document",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
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(
|
||||
@@ -189,6 +209,22 @@ async def update_service_center_application(
|
||||
)
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="service_center.update", target_type="service_center", target_id=center.id)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="sto_application_updated",
|
||||
title="СТО обновило заявку",
|
||||
body="\n".join(
|
||||
[
|
||||
f"Название: {center.display_name or center.name}",
|
||||
f"Город: {center.city or '-'}",
|
||||
f"Статус: {center.verification_status}",
|
||||
]
|
||||
),
|
||||
entity_type="service_center",
|
||||
entity_id=center.id,
|
||||
idempotency_key=f"sto_application_updated:{center.id}:{int(datetime.now(UTC).timestamp() // 60)}",
|
||||
metadata={"city": center.city, "owner_user_id": current_user.id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(center)
|
||||
return center
|
||||
@@ -827,6 +863,18 @@ async def create_service_center_review(
|
||||
await log_audit(session, actor=current_user, action="service_review.upsert", target_type="service_center", target_id=service_center_id)
|
||||
await session.flush()
|
||||
await refresh_service_rating(session, service_center_id)
|
||||
if review.rating <= 2:
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="sto_low_review",
|
||||
title="Низкая оценка СТО",
|
||||
body=f"СТО ID: {service_center_id}\nОценка: {review.rating}\nОтзыв: {review.text or '-'}",
|
||||
entity_type="service_center",
|
||||
entity_id=service_center_id,
|
||||
severity="warning",
|
||||
idempotency_key=f"sto_low_review:{review.id}:{review.rating}",
|
||||
metadata={"review_id": review.id, "rating": review.rating, "user_id": current_user.id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(review)
|
||||
return review
|
||||
|
||||
@@ -36,6 +36,7 @@ from app.schemas.sto_booking import (
|
||||
ServiceCenterHolidayRead,
|
||||
STODashboardRead,
|
||||
)
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.rate_limit import check_rate_limit
|
||||
from app.services.sto_booking import (
|
||||
calculate_available_slots,
|
||||
@@ -238,6 +239,28 @@ async def create_appointment(
|
||||
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
appointment_id=appointment.id,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="appointment_created",
|
||||
title="Новая запись в СТО",
|
||||
body="\n".join(
|
||||
[
|
||||
f"СТО ID: {appointment.service_center_id}",
|
||||
f"User ID: {current_user.id}",
|
||||
f"Авто ID: {appointment.vehicle_id}",
|
||||
f"Услуга: {appointment.service_name}",
|
||||
f"Время: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
]
|
||||
),
|
||||
entity_type="appointment",
|
||||
entity_id=appointment.id,
|
||||
idempotency_key=f"appointment_created:{appointment.id}",
|
||||
metadata={
|
||||
"service_center_id": appointment.service_center_id,
|
||||
"vehicle_id": appointment.vehicle_id,
|
||||
"owner_id": appointment.owner_id,
|
||||
},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -554,6 +577,17 @@ async def reject_appointment(
|
||||
title="СТО отклонило запись",
|
||||
body=payload.comment,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="appointment_cancelled",
|
||||
title="СТО отклонило запись",
|
||||
body=payload.comment,
|
||||
entity_type="appointment",
|
||||
entity_id=appointment.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"appointment_rejected_by_sto:{appointment.id}",
|
||||
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="appointment.reject", target_type="service_appointment", target_id=appointment_id)
|
||||
await session.commit()
|
||||
await session.refresh(appointment)
|
||||
@@ -579,6 +613,17 @@ async def delete_appointment_by_sto(
|
||||
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
idempotency_key=f"appointment:{appointment.id}:deleted_by_sto",
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="appointment_cancelled",
|
||||
title="СТО удалило запись",
|
||||
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
entity_type="appointment",
|
||||
entity_id=appointment.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"appointment_deleted_by_sto:{appointment.id}",
|
||||
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -677,6 +722,21 @@ async def create_work_order_from_appointment(
|
||||
body=visit.work_order_number,
|
||||
idempotency_key=f"work_order:{visit.id}:created",
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_created",
|
||||
title="Создан заказ-наряд",
|
||||
body=f"{visit.work_order_number or visit.id}: СТО {visit.service_center_id}, авто {visit.vehicle_id}",
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
idempotency_key=f"work_order_created:{visit.id}",
|
||||
metadata={
|
||||
"appointment_id": appointment.id,
|
||||
"service_center_id": visit.service_center_id,
|
||||
"vehicle_id": visit.vehicle_id,
|
||||
"owner_id": visit.owner_id,
|
||||
},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id})
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
|
||||
@@ -39,6 +39,7 @@ from app.schemas.service_center import (
|
||||
WorkOrderStatusHistoryRead,
|
||||
WorkOrderUpdate,
|
||||
)
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.sto_booking import create_service_notification
|
||||
from app.services.work_orders import (
|
||||
add_labor_item,
|
||||
@@ -461,6 +462,17 @@ async def reject_work_order(
|
||||
visit.owner_comment = payload.comment
|
||||
visit.owner_resolved_at = datetime.now(UTC)
|
||||
await add_status_history(session, visit, to_status="rejected_by_owner", actor=current_user, comment=payload.comment)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_rejected_by_owner",
|
||||
title="Владелец отклонил смету",
|
||||
body=payload.comment,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"work_order_rejected_by_owner:{visit.id}",
|
||||
metadata={"service_center_id": visit.service_center_id, "vehicle_id": visit.vehicle_id, "owner_id": current_user.id},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="work_order.reject", target_type="service_visit", target_id=visit.id)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
@@ -502,6 +514,21 @@ async def complete_work_order(
|
||||
actor=current_user,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_completed",
|
||||
title="Заказ-наряд завершён",
|
||||
body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}",
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
idempotency_key=f"work_order_completed:{visit.id}",
|
||||
metadata={
|
||||
"service_center_id": visit.service_center_id,
|
||||
"vehicle_id": visit.vehicle_id,
|
||||
"owner_id": visit.owner_id,
|
||||
"final_total": str(visit.final_total),
|
||||
},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="work_order.complete", target_type="service_visit", target_id=visit.id)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
@@ -599,6 +626,21 @@ async def create_work_order_correction(
|
||||
web_app_url=work_order_webapp_url(visit.id),
|
||||
button_text="Открыть заказ-наряд",
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_correction_requested",
|
||||
title="Запрошена коррекция заказ-наряда",
|
||||
body=payload.reason,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"work_order_correction_requested:{visit.id}:{visit.version or 1}:{payload.reason[:80]}",
|
||||
metadata={
|
||||
"service_center_id": visit.service_center_id,
|
||||
"vehicle_id": visit.vehicle_id,
|
||||
"owner_approval_required": payload.owner_approval_required,
|
||||
},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -626,6 +668,16 @@ async def approve_work_order_correction(
|
||||
raise HTTPException(status_code=409, detail="Correction is already resolved")
|
||||
correction.status = "approved"
|
||||
correction.resolved_at = datetime.now(UTC)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_correction_resolved",
|
||||
title="Коррекция заказ-наряда согласована",
|
||||
body=payload.comment,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
idempotency_key=f"work_order_correction_approved:{correction.id}",
|
||||
metadata={"correction_id": correction.id, "status": "approved"},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -653,6 +705,17 @@ async def reject_work_order_correction(
|
||||
raise HTTPException(status_code=409, detail="Correction is already resolved")
|
||||
correction.status = "rejected"
|
||||
correction.resolved_at = datetime.now(UTC)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_correction_resolved",
|
||||
title="Коррекция заказ-наряда отклонена",
|
||||
body=payload.comment,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"work_order_correction_rejected:{correction.id}",
|
||||
metadata={"correction_id": correction.id, "status": "rejected"},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
|
||||
@@ -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,47 @@ 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 OCRResult(Base):
|
||||
__tablename__ = "ocr_results"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
vehicle_id: Mapped[int | None] = mapped_column(ForeignKey("cars.id", ondelete="SET NULL"), index=True)
|
||||
scope: Mapped[str] = mapped_column(String(80), index=True)
|
||||
filename: Mapped[str | None] = mapped_column(String(255))
|
||||
content_type: Mapped[str | None] = mapped_column(String(120))
|
||||
status: Mapped[str] = mapped_column(String(24), default="preview", server_default="preview", index=True)
|
||||
provider: Mapped[str | None] = mapped_column(String(80))
|
||||
confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4))
|
||||
recognized_text: Mapped[str | None] = mapped_column(Text)
|
||||
candidates_json: Mapped[list | None] = mapped_column(JSON)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class ServiceWorkItem(Base):
|
||||
__tablename__ = "service_work_items"
|
||||
|
||||
@@ -625,3 +666,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)
|
||||
|
||||
185
app/services/admin_notifications.py
Normal file
185
app/services/admin_notifications.py
Normal file
@@ -0,0 +1,185 @@
|
||||
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",
|
||||
"sto_low_review": "admin_notify_sto_applications",
|
||||
"appointment_created": "admin_notify_sto_applications",
|
||||
"appointment_cancelled": "admin_notify_sto_applications",
|
||||
"work_order_created": "admin_notify_sto_applications",
|
||||
"work_order_completed": "admin_notify_sto_applications",
|
||||
"work_order_rejected_by_owner": "admin_notify_sto_applications",
|
||||
"work_order_correction_requested": "admin_notify_sto_applications",
|
||||
"work_order_correction_resolved": "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}"
|
||||
if entity_type == "vehicle" and entity_id:
|
||||
return f"{base}/admin.html?section=vehicles&entity_id={entity_id}"
|
||||
if entity_type == "appointment" and entity_id:
|
||||
return f"{base}/admin.html?section=appointments&entity_id={entity_id}"
|
||||
if entity_type == "work_order" and entity_id:
|
||||
return f"{base}/admin.html?section=work-orders&entity_id={entity_id}"
|
||||
if entity_type == "ocr_result" and entity_id:
|
||||
return f"{base}/admin.html?section=data&source=ocr_results&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,
|
||||
"reply_markup": {
|
||||
"inline_keyboard": [[{"text": "Открыть в админке", "url": link}]]
|
||||
},
|
||||
},
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
|
||||
477
tests/test_admin_control_center.py
Normal file
477
tests/test_admin_control_center.py
Normal file
@@ -0,0 +1,477 @@
|
||||
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_first_vehicle_and_first_record_create_admin_notifications(
|
||||
client, auth_headers, admin_auth_headers, internal_headers
|
||||
) -> None:
|
||||
await ensure_admin(client, internal_headers)
|
||||
vehicle = (
|
||||
await client.post(
|
||||
"/api/cars",
|
||||
headers=auth_headers,
|
||||
json={"name": "First admin-visible car", "current_odometer": 1500},
|
||||
)
|
||||
).json()
|
||||
fuel = await client.post(
|
||||
"/api/fuel",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"car_id": vehicle["id"],
|
||||
"entry_date": "2026-05-19",
|
||||
"odometer": 1510,
|
||||
"liters": 30,
|
||||
"price_per_liter": 2,
|
||||
},
|
||||
)
|
||||
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||
events = {item["event_type"] for item in notifications.json()["rows"]}
|
||||
|
||||
assert fuel.status_code == 201
|
||||
assert "vehicle_created" in events
|
||||
assert "first_record_created" in events
|
||||
|
||||
|
||||
@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_ocr_preview_is_available_in_admin_data_explorer(
|
||||
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": ("vin.txt", b"VIN KMHCT41BAHU123456", "text/plain")},
|
||||
)
|
||||
query = await client.post(
|
||||
"/api/admin/data/query",
|
||||
headers=admin_auth_headers,
|
||||
json={"source": "ocr_results", "category": "vin", "limit": 25},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert query.status_code == 200
|
||||
assert query.json()["rows"][0]["scope"] == "vin"
|
||||
assert query.json()["rows"][0]["status"] == "preview"
|
||||
|
||||
|
||||
@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())
|
||||
@@ -243,6 +243,9 @@ async def test_work_order_completion_creates_vehicle_records_and_updates_costs(
|
||||
assert refreshed.json()["engine_oil_type"] == "5W-30"
|
||||
assert refreshed.json()["engine_oil_volume_l"] == "4.00"
|
||||
assert stats.json()["total_cost"] == "130.00"
|
||||
admin_notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||
admin_events = {item["event_type"] for item in admin_notifications.json()["rows"]}
|
||||
assert {"work_order_completed", "work_order_correction_requested", "work_order_correction_resolved"} <= admin_events
|
||||
|
||||
cannot_edit = await client.patch(
|
||||
f"/api/work-orders/{work_order['id']}",
|
||||
|
||||
@@ -204,6 +204,9 @@ async def test_customer_booking_lifecycle_capacity_calendar_work_order_and_notif
|
||||
)
|
||||
assert work_order.status_code == 201
|
||||
assert work_order.json()["vehicle_id"] == vehicle["id"]
|
||||
admin_notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||
admin_events = {item["event_type"] for item in admin_notifications.json()["rows"]}
|
||||
assert {"appointment_created", "work_order_created"} <= admin_events
|
||||
|
||||
my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers)
|
||||
assert my_appointments.json()[0]["linked_work_order_id"] == work_order.json()["id"]
|
||||
|
||||
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>
|
||||
@@ -53,15 +53,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<form id="filterForm" class="grid-form drawer-form compact-form">
|
||||
<label>
|
||||
Город
|
||||
<input name="city" placeholder="Seoul" />
|
||||
</label>
|
||||
<label>
|
||||
Специализация
|
||||
<input name="specialization" placeholder="BMW, масло, тормоза" />
|
||||
</label>
|
||||
<button type="submit">Найти</button>
|
||||
<details class="advanced-fields compact-filter">
|
||||
<summary>Фильтры</summary>
|
||||
<label>
|
||||
Город
|
||||
<input name="city" placeholder="Seoul" />
|
||||
</label>
|
||||
<label>
|
||||
Специализация
|
||||
<input name="specialization" placeholder="BMW, масло, тормоза" />
|
||||
</label>
|
||||
<button type="submit">Найти</button>
|
||||
</details>
|
||||
</form>
|
||||
<div id="serviceList" class="stack-list"></div>
|
||||
</aside>
|
||||
@@ -89,6 +92,12 @@
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="preset-row quick-service-pills wide" aria-label="Быстрый выбор услуги">
|
||||
<button type="button" data-booking-service="oil_change">Масло</button>
|
||||
<button type="button" data-booking-service="diagnostics">Диагностика</button>
|
||||
<button type="button" data-booking-service="tire_service">Шины</button>
|
||||
<button type="button" data-booking-service="brakes">Тормоза</button>
|
||||
</div>
|
||||
<label>
|
||||
Длительность
|
||||
<select name="estimated_duration_minutes" id="durationSelect">
|
||||
|
||||
134
web/index.html
134
web/index.html
@@ -220,14 +220,19 @@
|
||||
Исполнитель
|
||||
<input name="vendor" placeholder="СТО / магазин" />
|
||||
</label>
|
||||
<label>
|
||||
Следующая дата
|
||||
<input name="next_due_date" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Следующий пробег
|
||||
<input name="next_due_odometer" type="number" min="0" />
|
||||
</label>
|
||||
<details class="advanced-fields wide">
|
||||
<summary>Напоминание о следующем ТО</summary>
|
||||
<div class="grid-form drawer-form compact-inner-form">
|
||||
<label>
|
||||
Следующая дата
|
||||
<input name="next_due_date" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Следующий пробег
|
||||
<input name="next_due_odometer" type="number" min="0" />
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="submit">Сохранить запись</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -292,6 +297,28 @@
|
||||
</div>
|
||||
|
||||
<div class="drawer-content">
|
||||
<section class="drawer-section hidden" id="quickAddSection">
|
||||
<h2>Добавить запись</h2>
|
||||
<div class="quick-entry-grid">
|
||||
<button type="button" data-quick-entry="fuelSection">
|
||||
<span>Заправка</span>
|
||||
<small>дата, пробег, литры, цена</small>
|
||||
</button>
|
||||
<button type="button" data-quick-entry="serviceSection">
|
||||
<span>ТО и ремонт</span>
|
||||
<small>работа, стоимость, следующий срок</small>
|
||||
</button>
|
||||
<button type="button" data-quick-entry="expensesSection">
|
||||
<span>Расход</span>
|
||||
<small>страховка, штраф, парковка, прочее</small>
|
||||
</button>
|
||||
<button type="button" data-quick-entry="scan">
|
||||
<span>Скан чека</span>
|
||||
<small>фото или файл</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section hidden" id="carsSection">
|
||||
<h2>Автомобили</h2>
|
||||
<div id="drawerCars" class="cars drawer-cars"></div>
|
||||
@@ -350,49 +377,54 @@
|
||||
Поставщик / место
|
||||
<input name="vendor" />
|
||||
</label>
|
||||
<label>
|
||||
Одометр
|
||||
<input name="odometer" type="number" min="0" />
|
||||
</label>
|
||||
<label>
|
||||
Начало периода
|
||||
<input name="period_start" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Конец периода
|
||||
<input name="period_end" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Месяцев покрытия
|
||||
<select name="period_months">
|
||||
<option value="">По датам</option>
|
||||
<option value="1">1 месяц</option>
|
||||
<option value="3">3 месяца</option>
|
||||
<option value="6">6 месяцев</option>
|
||||
<option value="12">12 месяцев</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Номер полиса / документа
|
||||
<input name="policy_number" />
|
||||
</label>
|
||||
<label>
|
||||
Тип страховки
|
||||
<select name="insurance_type">
|
||||
<option value="">Не задано</option>
|
||||
<option value="mandatory">ОСАГО / обязательная</option>
|
||||
<option value="full">КАСКО / полная</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Комментарий
|
||||
<input name="notes" />
|
||||
</label>
|
||||
<label class="check">
|
||||
<input name="is_recurring" type="checkbox" />
|
||||
Регулярный расход
|
||||
</label>
|
||||
<details class="advanced-fields wide">
|
||||
<summary>Дополнительно</summary>
|
||||
<div class="grid-form drawer-form compact-inner-form">
|
||||
<label>
|
||||
Одометр
|
||||
<input name="odometer" type="number" min="0" />
|
||||
</label>
|
||||
<label>
|
||||
Начало периода
|
||||
<input name="period_start" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Конец периода
|
||||
<input name="period_end" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Месяцев покрытия
|
||||
<select name="period_months">
|
||||
<option value="">По датам</option>
|
||||
<option value="1">1 месяц</option>
|
||||
<option value="3">3 месяца</option>
|
||||
<option value="6">6 месяцев</option>
|
||||
<option value="12">12 месяцев</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Номер полиса / документа
|
||||
<input name="policy_number" />
|
||||
</label>
|
||||
<label>
|
||||
Тип страховки
|
||||
<select name="insurance_type">
|
||||
<option value="">Не задано</option>
|
||||
<option value="mandatory">ОСАГО / обязательная</option>
|
||||
<option value="full">КАСКО / полная</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Комментарий
|
||||
<input name="notes" />
|
||||
</label>
|
||||
<label class="check">
|
||||
<input name="is_recurring" type="checkbox" />
|
||||
Регулярный расход
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="submit">Сохранить расход</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
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);
|
||||
@@ -4,6 +4,8 @@ tg?.expand();
|
||||
|
||||
const textNodes = new WeakMap();
|
||||
const attrOriginals = new WeakMap();
|
||||
let translationObserver = null;
|
||||
let translationTimer = null;
|
||||
|
||||
const i18n = {
|
||||
en: {
|
||||
@@ -83,6 +85,14 @@ const i18n = {
|
||||
"Марка": "Make",
|
||||
"Модель": "Model",
|
||||
"Добавить авто": "Add vehicle",
|
||||
"Добавить запись": "Add entry",
|
||||
"Расход": "Expense",
|
||||
"дата, пробег, литры, цена": "date, odometer, liters, price",
|
||||
"работа, стоимость, следующий срок": "work, cost, next due",
|
||||
"страховка, штраф, парковка, прочее": "insurance, fine, parking, other",
|
||||
"фото или файл": "photo or file",
|
||||
"Дополнительно": "More options",
|
||||
"Напоминание о следующем ТО": "Next maintenance reminder",
|
||||
"За весь срок": "All time",
|
||||
"За месяц": "This month",
|
||||
"За день": "Per day",
|
||||
@@ -215,6 +225,14 @@ const i18n = {
|
||||
"Марка": "브랜드",
|
||||
"Модель": "모델",
|
||||
"Добавить авто": "차량 추가",
|
||||
"Добавить запись": "기록 추가",
|
||||
"Расход": "지출",
|
||||
"дата, пробег, литры, цена": "날짜, 주행거리, 리터, 가격",
|
||||
"работа, стоимость, следующий срок": "작업, 비용, 다음 예정",
|
||||
"страховка, штраф, парковка, прочее": "보험, 벌금, 주차, 기타",
|
||||
"фото или файл": "사진 또는 파일",
|
||||
"Дополнительно": "추가 옵션",
|
||||
"Напоминание о следующем ТО": "다음 정비 알림",
|
||||
"За весь срок": "전체",
|
||||
"За месяц": "월",
|
||||
"За день": "일 평균",
|
||||
@@ -304,6 +322,15 @@ function applyTranslations(root = document.body) {
|
||||
});
|
||||
}
|
||||
|
||||
function observeTranslations(root = document.body) {
|
||||
if (translationObserver || !root) return;
|
||||
translationObserver = new MutationObserver(() => {
|
||||
window.clearTimeout(translationTimer);
|
||||
translationTimer = window.setTimeout(() => applyTranslations(root), 40);
|
||||
});
|
||||
translationObserver.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
|
||||
const state = {
|
||||
user: null,
|
||||
@@ -582,6 +609,7 @@ async function ensureUser() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ init_data: tg.initData }),
|
||||
});
|
||||
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||
hideAuthOverlay();
|
||||
updateRoleVisibility();
|
||||
return;
|
||||
@@ -590,6 +618,7 @@ async function ensureUser() {
|
||||
const devId = localStorage.getItem("driversDevTelegramId") || "1";
|
||||
localStorage.setItem("driversDevTelegramId", devId);
|
||||
state.user = await api("/users/me");
|
||||
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||
hideAuthOverlay();
|
||||
updateRoleVisibility();
|
||||
return;
|
||||
@@ -598,6 +627,36 @@ async function ensureUser() {
|
||||
throw new Error("Требуется вход через Telegram");
|
||||
}
|
||||
|
||||
function installLocaleSwitch() {
|
||||
const topActions = document.querySelector(".topbar .top-actions");
|
||||
if (!topActions || document.querySelector("#globalLocaleSelect")) return;
|
||||
const select = document.createElement("select");
|
||||
select.id = "globalLocaleSelect";
|
||||
select.className = "locale-switch";
|
||||
select.setAttribute("aria-label", "Язык");
|
||||
select.innerHTML = `
|
||||
<option value="ru">RU</option>
|
||||
<option value="en">EN</option>
|
||||
<option value="ko">KO</option>
|
||||
`;
|
||||
select.value = state.user?.locale || "ru";
|
||||
select.addEventListener("change", async () => {
|
||||
await runAction(select, "Сохраняю...", async () => {
|
||||
state.user = await api(`/users/${state.user.id}/preferences`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ locale: select.value, currency: state.user.currency }),
|
||||
});
|
||||
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||
document.querySelector("#localeSelect").value = state.user.locale || "ru";
|
||||
applyTranslations();
|
||||
renderCars();
|
||||
renderStats(state.latestStats);
|
||||
toast("Сохранено");
|
||||
});
|
||||
});
|
||||
topActions.prepend(select);
|
||||
}
|
||||
|
||||
function hideAuthOverlay() {
|
||||
document.querySelector("#authOverlay")?.classList.add("hidden");
|
||||
document.body.classList.remove("auth-required");
|
||||
@@ -2671,6 +2730,9 @@ document.querySelector("#settingsForm").addEventListener("submit", async (event)
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ locale: data.locale, currency: data.currency }),
|
||||
});
|
||||
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||
const globalLocale = document.querySelector("#globalLocaleSelect");
|
||||
if (globalLocale) globalLocale.value = state.user.locale || "ru";
|
||||
applyTranslations();
|
||||
initCarCatalog();
|
||||
await loadSelectedCar();
|
||||
@@ -2700,6 +2762,7 @@ document.querySelector("#fuelForm").addEventListener("submit", async (event) =>
|
||||
});
|
||||
form.reset();
|
||||
form.entry_date.value = today();
|
||||
form.is_full_tank.checked = true;
|
||||
await loadSelectedCar();
|
||||
toast("Сохранено");
|
||||
haptic("success");
|
||||
@@ -2802,6 +2865,20 @@ function mountEntryForms() {
|
||||
}
|
||||
}
|
||||
|
||||
function fillEntryDefaults(sectionId) {
|
||||
const car = selectedCar();
|
||||
const odometer = car?.current_odometer || "";
|
||||
const sections = sectionId ? [document.querySelector(`#${sectionId}`)] : [...document.querySelectorAll(".drawer-section")];
|
||||
sections.filter(Boolean).forEach((section) => {
|
||||
section.querySelectorAll('input[name="entry_date"]').forEach((input) => {
|
||||
if (!input.value) input.value = today();
|
||||
});
|
||||
section.querySelectorAll('input[name="odometer"]').forEach((input) => {
|
||||
if (!input.value && odometer) input.value = odometer;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openDrawerSection(sectionId, options = {}) {
|
||||
if (!canOpenDrawerSection(sectionId)) {
|
||||
toast("Этот раздел недоступен для вашей роли", "error");
|
||||
@@ -2817,6 +2894,7 @@ async function openDrawerSection(sectionId, options = {}) {
|
||||
button.classList.toggle("active", button.dataset.menuSection === sectionId);
|
||||
});
|
||||
mountEntryForms();
|
||||
fillEntryDefaults(sectionId);
|
||||
if (sectionId === "carProfileSection") fillCarProfileForm();
|
||||
if (sectionId === "settingsSection") {
|
||||
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
||||
@@ -2891,7 +2969,18 @@ document.querySelector("#addCarQuickBtn").addEventListener("click", () => {
|
||||
});
|
||||
|
||||
document.querySelector("#addRecordPrimaryBtn").addEventListener("click", () => {
|
||||
openDrawerSection("expensesSection");
|
||||
openDrawerSection("quickAddSection");
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-quick-entry]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
haptic();
|
||||
if (button.dataset.quickEntry === "scan") {
|
||||
openScanModal();
|
||||
return;
|
||||
}
|
||||
await openDrawerSection(button.dataset.quickEntry);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-menu-section]").forEach((button) => {
|
||||
@@ -3043,6 +3132,8 @@ initPwa();
|
||||
Promise.all([loadAuthConfig()])
|
||||
.then(() => Promise.all([ensureUser(), loadCatalog()]))
|
||||
.then(() => {
|
||||
installLocaleSwitch();
|
||||
observeTranslations();
|
||||
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
||||
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
|
||||
document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB";
|
||||
|
||||
@@ -113,6 +113,16 @@ document.querySelector("#filterForm").addEventListener("submit", async (event) =
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-booking-service]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
document.querySelector("#serviceTypeSelect").value = button.dataset.bookingService;
|
||||
document.querySelectorAll("[data-booking-service]").forEach((item) => {
|
||||
item.classList.toggle("active", item === button);
|
||||
});
|
||||
await loadSlots().catch((error) => page.toast(error.message || "Не удалось обновить окна", "error"));
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#bookingForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const center = selectedCenter();
|
||||
|
||||
@@ -2,6 +2,238 @@ const tg = window.Telegram?.WebApp;
|
||||
tg?.ready();
|
||||
tg?.expand();
|
||||
|
||||
const CarPassI18n = (() => {
|
||||
const textNodes = new WeakMap();
|
||||
const attrOriginals = new WeakMap();
|
||||
let observer = null;
|
||||
let timer = null;
|
||||
const dictionaries = {
|
||||
en: {
|
||||
"Гараж": "Garage",
|
||||
"Автомобили": "Vehicles",
|
||||
"Автомобиль": "Vehicle",
|
||||
"Авто": "Vehicles",
|
||||
"Заправка": "Fuel",
|
||||
"Сервис": "Service",
|
||||
"Расход": "Expense",
|
||||
"Расходы": "Expenses",
|
||||
"ТО и ремонт": "Maintenance and repair",
|
||||
"Дата": "Date",
|
||||
"Одометр, км": "Odometer, km",
|
||||
"Одометр": "Odometer",
|
||||
"Литры": "Liters",
|
||||
"Цена за литр": "Price per liter",
|
||||
"АЗС": "Fuel station",
|
||||
"Полный бак": "Full tank",
|
||||
"Стоимость": "Cost",
|
||||
"Валюта": "Currency",
|
||||
"Категория": "Category",
|
||||
"Название": "Title",
|
||||
"Комментарий": "Comment",
|
||||
"Сохранить": "Save",
|
||||
"Сохранить запись": "Save entry",
|
||||
"Сохранить расход": "Save expense",
|
||||
"Сохранить заправку": "Save fuel entry",
|
||||
"Сохранить настройки": "Save settings",
|
||||
"Создать запись": "Create booking",
|
||||
"Запись в СТО": "Book service",
|
||||
"СТО": "Service centers",
|
||||
"Сервисы": "Services",
|
||||
"Каталог": "Catalog",
|
||||
"Заявка": "Request",
|
||||
"Выберите сервис": "Choose service",
|
||||
"Город": "City",
|
||||
"Специализация": "Specialization",
|
||||
"Найти": "Search",
|
||||
"Что нужно сделать": "What needs to be done",
|
||||
"Услуга": "Service",
|
||||
"Длительность": "Duration",
|
||||
"Свободное окно": "Available slot",
|
||||
"Окно записи": "Booking slot",
|
||||
"Отправить заявку": "Send request",
|
||||
"Проверить карточку авто": "Check vehicle card",
|
||||
"Меню": "Menu",
|
||||
"Проверить вход": "Check login",
|
||||
"Открыть в Telegram": "Open in Telegram",
|
||||
"Обновить": "Refresh",
|
||||
"Настройки": "Settings",
|
||||
"Язык": "Language",
|
||||
"Панель СТО": "Service workplace",
|
||||
"Записи клиентов": "Client bookings",
|
||||
"Заказ-наряды": "Work orders",
|
||||
"Сотрудники": "Staff",
|
||||
"Пригласить": "Invite",
|
||||
"Заказ-наряд": "Work order",
|
||||
"Работы": "Labor",
|
||||
"Запчасти и жидкости": "Parts and fluids",
|
||||
"Проверьте смету": "Review estimate",
|
||||
"Согласовать": "Approve",
|
||||
"Отклонить": "Reject",
|
||||
"Админ-панель": "Admin panel",
|
||||
"Операционный обзор": "Operational overview",
|
||||
"Последние события": "Latest events",
|
||||
"Быстрые переходы": "Quick links",
|
||||
"Заявки СТО": "Service applications",
|
||||
"Записи": "Bookings",
|
||||
"Фильтр": "Filter",
|
||||
"Фильтры": "Filters",
|
||||
"Запросить": "Query",
|
||||
"Показать": "Show",
|
||||
"Импорт и экспорт": "Import and export",
|
||||
"Скачать JSON": "Download JSON",
|
||||
"Проверить файл": "Preview file",
|
||||
"Импортировать": "Import",
|
||||
"Паспорт автомобиля": "Vehicle passport",
|
||||
"Выберите автомобиль": "Choose vehicle",
|
||||
"Параметры авто": "Vehicle settings",
|
||||
"Сохранить паспорт": "Save passport",
|
||||
"Удалить автомобиль": "Delete vehicle",
|
||||
"Готов к работе": "Ready",
|
||||
"Сохраняю...": "Saving...",
|
||||
"Сохранено": "Saved",
|
||||
"Ошибка": "Error",
|
||||
"Нет данных": "No data",
|
||||
"Нет доступа": "No access",
|
||||
},
|
||||
ko: {
|
||||
"Гараж": "차고",
|
||||
"Автомобили": "차량",
|
||||
"Автомобиль": "차량",
|
||||
"Авто": "차량",
|
||||
"Заправка": "주유",
|
||||
"Сервис": "정비",
|
||||
"Расход": "지출",
|
||||
"Расходы": "지출",
|
||||
"ТО и ремонт": "정비 및 수리",
|
||||
"Дата": "날짜",
|
||||
"Одометр, км": "주행거리, km",
|
||||
"Одометр": "주행거리",
|
||||
"Литры": "리터",
|
||||
"Цена за литр": "리터당 가격",
|
||||
"АЗС": "주유소",
|
||||
"Полный бак": "가득 주유",
|
||||
"Стоимость": "비용",
|
||||
"Валюта": "통화",
|
||||
"Категория": "카테고리",
|
||||
"Название": "제목",
|
||||
"Комментарий": "메모",
|
||||
"Сохранить": "저장",
|
||||
"Сохранить запись": "기록 저장",
|
||||
"Сохранить расход": "지출 저장",
|
||||
"Сохранить заправку": "주유 저장",
|
||||
"Сохранить настройки": "설정 저장",
|
||||
"Создать запись": "예약 생성",
|
||||
"Запись в СТО": "정비소 예약",
|
||||
"СТО": "정비소",
|
||||
"Сервисы": "서비스",
|
||||
"Каталог": "목록",
|
||||
"Заявка": "요청",
|
||||
"Выберите сервис": "정비소 선택",
|
||||
"Город": "도시",
|
||||
"Специализация": "전문 분야",
|
||||
"Найти": "검색",
|
||||
"Что нужно сделать": "필요한 작업",
|
||||
"Услуга": "서비스",
|
||||
"Длительность": "소요 시간",
|
||||
"Свободное окно": "예약 가능 시간",
|
||||
"Окно записи": "예약 시간",
|
||||
"Отправить заявку": "요청 보내기",
|
||||
"Проверить карточку авто": "차량 카드 확인",
|
||||
"Меню": "메뉴",
|
||||
"Проверить вход": "로그인 확인",
|
||||
"Открыть в Telegram": "텔레그램에서 열기",
|
||||
"Обновить": "새로고침",
|
||||
"Настройки": "설정",
|
||||
"Язык": "언어",
|
||||
"Панель СТО": "정비소 작업실",
|
||||
"Записи клиентов": "고객 예약",
|
||||
"Заказ-наряды": "작업지시서",
|
||||
"Сотрудники": "직원",
|
||||
"Пригласить": "초대",
|
||||
"Заказ-наряд": "작업지시서",
|
||||
"Работы": "공임",
|
||||
"Запчасти и жидкости": "부품 및 오일",
|
||||
"Проверьте смету": "견적 확인",
|
||||
"Согласовать": "승인",
|
||||
"Отклонить": "거절",
|
||||
"Админ-панель": "관리자 패널",
|
||||
"Операционный обзор": "운영 요약",
|
||||
"Последние события": "최근 이벤트",
|
||||
"Быстрые переходы": "빠른 이동",
|
||||
"Заявки СТО": "정비소 신청",
|
||||
"Записи": "예약",
|
||||
"Фильтр": "필터",
|
||||
"Фильтры": "필터",
|
||||
"Запросить": "조회",
|
||||
"Показать": "보기",
|
||||
"Импорт и экспорт": "가져오기/내보내기",
|
||||
"Скачать JSON": "JSON 다운로드",
|
||||
"Проверить файл": "파일 확인",
|
||||
"Импортировать": "가져오기",
|
||||
"Паспорт автомобиля": "차량 패스포트",
|
||||
"Выберите автомобиль": "차량 선택",
|
||||
"Параметры авто": "차량 설정",
|
||||
"Сохранить паспорт": "패스포트 저장",
|
||||
"Удалить автомобиль": "차량 삭제",
|
||||
"Готов к работе": "준비 완료",
|
||||
"Сохраняю...": "저장 중...",
|
||||
"Сохранено": "저장됨",
|
||||
"Ошибка": "오류",
|
||||
"Нет данных": "데이터 없음",
|
||||
"Нет доступа": "접근 불가",
|
||||
},
|
||||
};
|
||||
|
||||
function t(text, locale = currentLocale()) {
|
||||
return dictionaries[locale]?.[text] || text;
|
||||
}
|
||||
|
||||
function currentLocale() {
|
||||
return window.CarPassPage?.state?.user?.locale || localStorage.getItem("carpassLocale") || "ru";
|
||||
}
|
||||
|
||||
function apply(root = document.body, locale = currentLocale()) {
|
||||
document.documentElement.lang = locale;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent || ["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "SELECT"].includes(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
||||
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||
},
|
||||
});
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
if (!textNodes.has(node)) textNodes.set(node, node.nodeValue.trim());
|
||||
const original = textNodes.get(node);
|
||||
node.nodeValue = node.nodeValue.replace(node.nodeValue.trim(), t(original, locale));
|
||||
}
|
||||
root.querySelectorAll?.("[placeholder], [aria-label], [title]").forEach((element) => {
|
||||
["placeholder", "aria-label", "title"].forEach((attr) => {
|
||||
const value = element.getAttribute(attr);
|
||||
if (!value) return;
|
||||
let originals = attrOriginals.get(element);
|
||||
if (!originals) {
|
||||
originals = {};
|
||||
attrOriginals.set(element, originals);
|
||||
}
|
||||
originals[attr] ||= value;
|
||||
element.setAttribute(attr, t(originals[attr], locale));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function observe(root = document.body) {
|
||||
if (observer || !root) return;
|
||||
observer = new MutationObserver(() => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => apply(root), 40);
|
||||
});
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
return { apply, t, currentLocale, observe };
|
||||
})();
|
||||
|
||||
const CarPassPage = (() => {
|
||||
const state = { user: null, authConfig: null };
|
||||
|
||||
@@ -51,13 +283,14 @@ const CarPassPage = (() => {
|
||||
}
|
||||
document.body.classList.remove("auth-required");
|
||||
document.querySelector("#authOverlay")?.classList.add("hidden");
|
||||
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||
return state.user;
|
||||
}
|
||||
|
||||
function toast(message, tone = "success") {
|
||||
const node = document.querySelector("#toast");
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.textContent = t(message);
|
||||
node.className = `toast ${tone}`;
|
||||
window.clearTimeout(toast.timer);
|
||||
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
|
||||
@@ -69,7 +302,7 @@ const CarPassPage = (() => {
|
||||
button.dataset.label = button.textContent;
|
||||
button.disabled = true;
|
||||
button.classList.add("is-busy");
|
||||
button.innerHTML = `<span class="spinner"></span><span>${label}</span>`;
|
||||
button.innerHTML = `<span class="spinner"></span><span>${t(label)}</span>`;
|
||||
} else {
|
||||
button.disabled = false;
|
||||
button.classList.remove("is-busy");
|
||||
@@ -115,18 +348,73 @@ const CarPassPage = (() => {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " ");
|
||||
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
const locale = { ru: "ru-RU", en: "en-US", ko: "ko-KR" }[state.user?.locale] || "ru-RU";
|
||||
return date.toLocaleString(locale, { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function t(text) {
|
||||
return CarPassI18n.t(text, state.user?.locale || "ru");
|
||||
}
|
||||
|
||||
function applyTranslations(root = document.body) {
|
||||
CarPassI18n.apply(root, state.user?.locale || "ru");
|
||||
}
|
||||
|
||||
async function updateLocale(locale) {
|
||||
if (!state.user || state.user.locale === locale) return;
|
||||
state.user = await api(`/users/${state.user.id}/preferences`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ locale }),
|
||||
});
|
||||
localStorage.setItem("carpassLocale", locale);
|
||||
applyTranslations();
|
||||
}
|
||||
|
||||
function installLocaleSwitch() {
|
||||
const topbar = document.querySelector(".topbar");
|
||||
if (!topbar || document.querySelector("#globalLocaleSelect")) return;
|
||||
let host = topbar.querySelector(".top-actions");
|
||||
if (!host) {
|
||||
host = document.createElement("div");
|
||||
host.className = "top-actions";
|
||||
topbar.appendChild(host);
|
||||
}
|
||||
const select = document.createElement("select");
|
||||
select.id = "globalLocaleSelect";
|
||||
select.className = "locale-switch";
|
||||
select.setAttribute("aria-label", "Язык");
|
||||
select.innerHTML = `
|
||||
<option value="ru">RU</option>
|
||||
<option value="en">EN</option>
|
||||
<option value="ko">KO</option>
|
||||
`;
|
||||
select.value = state.user?.locale || "ru";
|
||||
select.addEventListener("change", async () => {
|
||||
try {
|
||||
await updateLocale(select.value);
|
||||
toast("Сохранено");
|
||||
} catch (error) {
|
||||
toast(error.message || "Ошибка", "error");
|
||||
}
|
||||
});
|
||||
const primarySelect = host.querySelector("select:not(.locale-switch)");
|
||||
if (primarySelect) primarySelect.insertAdjacentElement("afterend", select);
|
||||
else host.prepend(select);
|
||||
}
|
||||
|
||||
async function boot(init) {
|
||||
try {
|
||||
await loadAuthConfig();
|
||||
await ensureUser();
|
||||
installLocaleSwitch();
|
||||
applyTranslations();
|
||||
CarPassI18n.observe();
|
||||
await init();
|
||||
applyTranslations();
|
||||
} catch (error) {
|
||||
if (error.message === "Требуется вход через Telegram") return;
|
||||
console.error(error);
|
||||
@@ -148,5 +436,9 @@ const CarPassPage = (() => {
|
||||
csvList,
|
||||
formatDateTime,
|
||||
today,
|
||||
t,
|
||||
applyTranslations,
|
||||
installLocaleSwitch,
|
||||
updateLocale,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
@@ -2358,7 +2579,7 @@ select {
|
||||
|
||||
.sto-page .top-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
grid-template-columns: minmax(0, 1fr) 58px 38px;
|
||||
}
|
||||
|
||||
.staff-form button {
|
||||
@@ -2376,3 +2597,265 @@ select {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact UX pass */
|
||||
.locale-switch {
|
||||
width: 66px;
|
||||
min-height: 34px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.shell {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding-block: 9px;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-size: clamp(23px, 4vw, 34px);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
button,
|
||||
.ghost-btn {
|
||||
min-height: 38px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 38px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
min-height: 38px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
label {
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
min-height: 92px;
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
font-size: clamp(19px, 3.2vw, 27px);
|
||||
}
|
||||
|
||||
.primary-add-btn {
|
||||
min-height: 46px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.workspace,
|
||||
.chart-card {
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.passport-panel {
|
||||
gap: 8px;
|
||||
padding: 11px;
|
||||
}
|
||||
|
||||
.passport-head h2,
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.stats,
|
||||
.hero-grid,
|
||||
.layout,
|
||||
.charts,
|
||||
.flow-layout,
|
||||
.sto-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat,
|
||||
.stat-card {
|
||||
min-height: 76px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stat strong,
|
||||
.stat-card strong {
|
||||
font-size: clamp(18px, 2.1vw, 23px);
|
||||
}
|
||||
|
||||
.grid-form,
|
||||
.entry-form,
|
||||
.flow-form {
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.entry-form {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
width: min(560px, 100%);
|
||||
gap: 9px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.drawer-menu {
|
||||
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: min(28vh, 230px);
|
||||
}
|
||||
|
||||
.menu-group {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.menu-row {
|
||||
min-height: 40px;
|
||||
padding-inline: 11px;
|
||||
}
|
||||
|
||||
.quick-entry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.quick-entry-grid button {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-height: 82px;
|
||||
align-content: center;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 8px 20px rgba(27, 38, 34, 0.06);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quick-entry-grid button span {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.quick-entry-grid button small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.advanced-fields {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfdfc;
|
||||
}
|
||||
|
||||
.advanced-fields summary {
|
||||
min-height: 38px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.advanced-fields summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.advanced-fields summary::after {
|
||||
content: "+";
|
||||
float: right;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.advanced-fields[open] summary::after {
|
||||
content: "-";
|
||||
}
|
||||
|
||||
.compact-inner-form {
|
||||
margin: 0;
|
||||
padding: 0 10px 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.compact-filter {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.compact-filter label,
|
||||
.compact-filter button {
|
||||
margin: 0 10px 10px;
|
||||
}
|
||||
|
||||
.quick-service-pills {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.quick-service-pills button.active {
|
||||
border-color: rgba(18, 115, 95, 0.45);
|
||||
background: #e7f4ef;
|
||||
color: #0e604f;
|
||||
}
|
||||
|
||||
.flow-hero {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.flow-hero h2 {
|
||||
font-size: clamp(20px, 2.4vw, 28px);
|
||||
}
|
||||
|
||||
.form-block {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.top-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.locale-switch {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.quick-entry-grid,
|
||||
.compact-inner-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.drawer-menu {
|
||||
grid-template-columns: 1fr;
|
||||
max-height: min(32vh, 260px);
|
||||
}
|
||||
|
||||
.summary-card,
|
||||
.stat {
|
||||
min-height: 74px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user