Admin panel was added. TG bot commands were extended.
This commit is contained in:
parent
9c9181bc50
commit
3828b46985
36 changed files with 2054 additions and 55 deletions
|
|
@ -72,6 +72,12 @@ JWT_REFRESH_TTL_DAYS=30 # refresh-token lifetime for webUI auth
|
|||
|
||||
# --- WebUI auth (email + password) ---
|
||||
WEB_AUTH_ENABLED=true # toggle /api/v1/auth/{register,login,...} routes
|
||||
WEB_ADMIN_ENABLED=true # toggle the /admin management UI (users role = 'admin')
|
||||
ADMIN_REQUIRED_ROLE=admin # users.role value required to enter /admin
|
||||
ADMIN_DEFAULT_EMAIL=admin@contract-check.local
|
||||
# Default admin password. Set once on first deploy; the user is auto-created with role=admin.
|
||||
# Leave empty to disable default admin creation (create admins manually via DB or register).
|
||||
ADMIN_DEFAULT_PASSWORD=changeme-strong-password
|
||||
PASSWORD_RESET_TTL_MINUTES=60
|
||||
PASSWORD_MIN_LENGTH=8
|
||||
WEB_APP_BASE_URL=http://localhost:5173 # SPA base — used to build reset links
|
||||
|
|
|
|||
18
Makefile
18
Makefile
|
|
@ -5,7 +5,7 @@
|
|||
infra-up infra-down infra-logs services-up services-down services-logs \
|
||||
api api-logs bot bot-logs worker-extract worker-analyze worker-notify \
|
||||
seed-token jwt-secret jwt-token jwt-verify health shell-api shell-bot \
|
||||
shell-db clean
|
||||
shell-db admin-promote admin-list clean
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Help
|
||||
|
|
@ -103,6 +103,22 @@ migrate: ## Run Alembic migrations (inside api container)
|
|||
shell-db: ## Open psql inside postgres container
|
||||
docker compose exec postgres psql -U contract_check -d contract_check
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Admin panel (/admin — manage users; needs role = 'admin')
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
admin-promote: ## Grant admin role to a user (usage: make admin-promote EMAIL=a@b.c)
|
||||
@if [ -z "$(EMAIL)" ]; then \
|
||||
echo "Usage: make admin-promote EMAIL=a@b.c"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@docker compose exec -T postgres psql -U contract_check -d contract_check \
|
||||
-c "UPDATE users SET role = 'admin' WHERE email = '$(EMAIL)';" \
|
||||
-c "SELECT id, email, role FROM users WHERE email = '$(EMAIL)';"
|
||||
|
||||
admin-list: ## List current admin users
|
||||
@docker compose exec -T postgres psql -U contract_check -d contract_check \
|
||||
-c "SELECT id, email, telegram_id, role, is_active FROM users WHERE role = 'admin';"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Auth / tokens
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ src/contract_check/
|
|||
analysis/ extractor.py chunker.py checklist.py report_schema.py ocr.py analyzer.py
|
||||
api/ # FastAPI-образ
|
||||
app.py deps.py middleware.py services.py __main__.py
|
||||
admin/ # серверный UI по /admin (users; позже — подписки)
|
||||
routes/ health.py documents.py reports.py me.py metrics.py b2b.py
|
||||
worker_extract/ # CPU-образ (pymupdf + tesseract)
|
||||
consumer.py handler.py extract_document.py __main__.py
|
||||
|
|
@ -120,12 +121,13 @@ uv run python -m contract_check prototype contract.pdf --json metrics.json # +
|
|||
|
||||
## API (кратко)
|
||||
|
||||
Все роуты под `/api/v1`. Auth зависит от роута:
|
||||
JSON-роуты под `/api/v1`; серверный admin UI — по `/admin/*` (FastAPI + Jinja2 + HTMX). Auth зависит от роута:
|
||||
|
||||
- **Пользовательские роуты** (bot / web / Mini App) — `Authorization: Bearer <user_jwt>`. JWT выдаётся через `/api/v1/auth/telegram/*` после проверки identity от Telegram.
|
||||
- **Адаптер-level** (только `/api/v1/auth/telegram/bot`) — `Authorization: Bearer <service_token>`.
|
||||
- **B2B** — `X-API-Key`. Управление B2B-ключами требует пользовательский JWT.
|
||||
- **Health/metrics** — без auth.
|
||||
- **/admin** — HttpOnly cookie с user JWT + `users.role = admin` (или `ADMIN_REQUIRED_ROLE`).
|
||||
|
||||
| Метод | Путь | Auth | Назначение |
|
||||
|---|---|---|---|
|
||||
|
|
@ -133,6 +135,8 @@ uv run python -m contract_check prototype contract.pdf --json metrics.json # +
|
|||
| POST | `/api/v1/auth/telegram/bot` | service token | бот меняет verified `telegram_id` на JWT |
|
||||
| POST | `/api/v1/auth/telegram/web` | — | Telegram Login Widget → JWT |
|
||||
| POST | `/api/v1/auth/telegram/miniapp` | — | Mini App `initData` → JWT |
|
||||
| POST | `/api/v1/auth/register` | — | регистрация email/password |
|
||||
| POST | `/api/v1/auth/login` | — | вход email/password → JWT-пара |
|
||||
| GET | `/api/v1/auth/me` | user JWT | introspect JWT |
|
||||
| POST | `/api/v1/documents` | user JWT | multipart upload → reserve credit → MinIO → publish → `202 {document_id, correlation_id}` |
|
||||
| GET | `/api/v1/documents/{id}` | user JWT | статус + stage (для поллинга) |
|
||||
|
|
@ -141,6 +145,7 @@ uv run python -m contract_check prototype contract.pdf --json metrics.json # +
|
|||
| POST | `/api/v1/analyze` | `X-API-Key` | B2B: анализ документа |
|
||||
| GET | `/api/v1/b2b/reports/{id}`, `/api/v1/b2b/usage` | `X-API-Key` | B2B: отчёт / usage |
|
||||
| POST/GET | `/api/v1/b2b/keys`, `/api/v1/b2b/keys/{id}/revoke`, `.../usage` | user JWT | управление B2B-ключами |
|
||||
| GET/POST | `/admin/users*` | admin cookie | управление пользователями (list, create, edit, ban, role, credits) |
|
||||
|
||||
Полная спецификация — `docs/ARCHITECTURE.md §15` (актуализируется).
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,10 @@ services:
|
|||
JWT_ACCESS_TTL_MINUTES: ${JWT_ACCESS_TTL_MINUTES:-1440}
|
||||
JWT_REFRESH_TTL_DAYS: ${JWT_REFRESH_TTL_DAYS:-30}
|
||||
WEB_AUTH_ENABLED: ${WEB_AUTH_ENABLED:-true}
|
||||
WEB_ADMIN_ENABLED: ${WEB_ADMIN_ENABLED:-true}
|
||||
ADMIN_REQUIRED_ROLE: ${ADMIN_REQUIRED_ROLE:-admin}
|
||||
ADMIN_DEFAULT_EMAIL: ${ADMIN_DEFAULT_EMAIL:-admin@contract-check.local}
|
||||
ADMIN_DEFAULT_PASSWORD: ${ADMIN_DEFAULT_PASSWORD:-}
|
||||
PASSWORD_RESET_TTL_MINUTES: ${PASSWORD_RESET_TTL_MINUTES:-60}
|
||||
PASSWORD_MIN_LENGTH: ${PASSWORD_MIN_LENGTH:-8}
|
||||
WEB_APP_BASE_URL: ${WEB_APP_BASE_URL:-http://localhost:5173}
|
||||
|
|
@ -299,6 +303,9 @@ services:
|
|||
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
OTEL_SERVICE_NAME: worker-notify
|
||||
S3_ENDPOINT_URL: http://minio:9000
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-contract_check}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:-contract_check}
|
||||
MQ_PREFETCH_NOTIFY: ${MQ_PREFETCH_NOTIFY:-5}
|
||||
MQ_MAX_ATTEMPTS: ${MQ_MAX_ATTEMPTS:-5}
|
||||
MQ_RETRY_BASE_MS: ${MQ_RETRY_BASE_MS:-2000}
|
||||
|
|
@ -309,6 +316,9 @@ services:
|
|||
SMTP_FROM: ${SMTP_FROM:-no-reply@contract-check.local}
|
||||
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
|
||||
WEB_APP_BASE_URL: ${WEB_APP_BASE_URL:-http://localhost:5173}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256}
|
||||
JWT_ACCESS_TTL_MINUTES: ${JWT_ACCESS_TTL_MINUTES:-1440}
|
||||
ports:
|
||||
- "9103:9103"
|
||||
|
||||
|
|
|
|||
|
|
@ -423,11 +423,17 @@ before api/worker start. Alternatively `core/s3/minio_storage.py` does
|
|||
|
||||
---
|
||||
|
||||
## 7. Postgres schema (initial Alembic migration)
|
||||
## 7. Postgres schema (Alembic migrations)
|
||||
|
||||
Six tables. `status`/`queue`/`adapter` columns are `TEXT + CHECK` (not
|
||||
Postgres enums) so migrations are additive — matches the existing convention
|
||||
noted in `IMPLEMENTATION_PLAN.md` §1.2.
|
||||
Six core tables plus additive migrations. `status`/`queue`/`adapter`/`role`
|
||||
columns are `TEXT + CHECK` (not Postgres enums) so migrations are additive —
|
||||
matches the convention noted in `IMPLEMENTATION_PLAN.md` §1.2.
|
||||
|
||||
Migrations (hand-written, async `env.py`):
|
||||
- `0001` initial schema: users/documents/reports/jobs/service_tokens/invoices
|
||||
- `0002` `api_keys` + `api_key_requests` (B2B)
|
||||
- `0003` webUI auth columns on `users` (`email`, `password_hash`, reset tokens, `is_active`)
|
||||
- `0004` `users.role` for the admin panel (`'user'|'admin'`, default `'user'`)
|
||||
|
||||
```sql
|
||||
-- users
|
||||
|
|
@ -436,8 +442,17 @@ CREATE TABLE users (
|
|||
telegram_id BIGINT UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
credits_left INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT users_credits_nonneg CHECK (credits_left >= 0)
|
||||
email TEXT UNIQUE, -- added by 0003
|
||||
password_hash TEXT, -- added by 0003
|
||||
password_reset_token_hash TEXT, -- added by 0003
|
||||
password_reset_expires_at TIMESTAMPTZ, -- added by 0003
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- added by 0003
|
||||
role TEXT NOT NULL DEFAULT 'user' -- added by 0004
|
||||
CHECK (role IN ('user','admin')),
|
||||
CONSTRAINT users_credits_nonneg CHECK (credits_left >= 0),
|
||||
CONSTRAINT users_identity_present CHECK (telegram_id IS NOT NULL OR email IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX users_role_idx ON users (role); -- added by 0004
|
||||
|
||||
-- documents
|
||||
CREATE TABLE documents (
|
||||
|
|
@ -725,7 +740,7 @@ None of 1–5 requires touching `core/` application code — only compose/infra.
|
|||
|---|---|---|
|
||||
| `ENV` | `dev` | dev/staging/prod — toggles TLS, sentry sample rate |
|
||||
| `LOG_LEVEL` | `INFO` | structlog level |
|
||||
| `LOG_FORMAT` | `json` | `json` (prod/staging) \| `console` (dev) — explicit override of env-based default |
|
||||
| `LOG_FORMAT` | `json` | `json` (prod/staging) or `console` (dev) — explicit override of env-based default |
|
||||
| `APP_VERSION` | `unknown` | added to every log line; set at build/deploy time |
|
||||
| `DATABASE_URL` | (required) | `postgresql+asyncpg://...` |
|
||||
| `REDIS_URL` | `redis://redis:6379/0` | rate limit / sessions (future) |
|
||||
|
|
@ -763,15 +778,15 @@ None of 1–5 requires touching `core/` application code — only compose/infra.
|
|||
|
||||
### API
|
||||
|
||||
| Env | Default |
|
||||
| Env | Default | Notes |
|
||||
|---|---|---|
|
||||
| `API_HOST` | `0.0.0.0` |
|
||||
| `API_PORT` | `8000` |
|
||||
| `API_METRICS_PORT` | `9100` |
|
||||
| `B2B_DEFAULT_RATE_LIMIT_RPS` | `3` (per API key; mirrors Ollama Pro concurrency, overridable per `api_keys.rate_limit_rps`) |
|
||||
| `CORS_ORIGINS` | (empty, future web) |
|
||||
| `API_HOST` | `0.0.0.0` | |
|
||||
| `API_PORT` | `8000` | |
|
||||
| `API_METRICS_PORT` | `9100` | |
|
||||
| `B2B_DEFAULT_RATE_LIMIT_RPS` | `3` | per API key; mirrors Ollama Pro concurrency, overridable per `api_keys.rate_limit_rps` |
|
||||
| `CORS_ORIGINS` | (empty, future web) | |
|
||||
|
||||
### Auth (JWT + Telegram identity verification)
|
||||
### Auth (JWT + Telegram identity verification + webUI + admin panel)
|
||||
|
||||
| Env | Default | Notes |
|
||||
|---|---|---|
|
||||
|
|
@ -779,11 +794,18 @@ None of 1–5 requires touching `core/` application code — only compose/infra.
|
|||
| `JWT_SECRET` | (required) | HS256 secret for signing user JWTs; generate with `openssl rand -hex 32` |
|
||||
| `JWT_ALGORITHM` | `HS256` | |
|
||||
| `JWT_ACCESS_TTL_MINUTES` | `1440` (24h) | access-token lifetime; tune per env |
|
||||
| `JWT_REFRESH_TTL_DAYS` | `30` | refresh-token lifetime for webUI sessions |
|
||||
| `WEB_AUTH_ENABLED` | `true` | toggle `/api/v1/auth/{register,login,logout,forgot-password,reset-password}` |
|
||||
| `WEB_APP_BASE_URL` | `http://localhost:5173` | base URL of the future web SPA; used for password-reset links |
|
||||
| `PASSWORD_RESET_TTL_MINUTES` | `60` | reset-link validity |
|
||||
| `PASSWORD_MIN_LENGTH` | `8` | enforced at register, reset, and in the `/admin` create form |
|
||||
| `WEB_ADMIN_ENABLED` | `true` | toggle the `/admin/*` server-rendered management UI |
|
||||
| `ADMIN_REQUIRED_ROLE` | `admin` | `users.role` value required to enter `/admin` (must match the DB CHECK constraint) |
|
||||
|
||||
### Bot
|
||||
|
||||
| Env | Default |
|
||||
|---|---|---|
|
||||
|---|---|
|
||||
| `BOT_TOKEN` | (required) |
|
||||
| `API_URL` | `http://api:8000` |
|
||||
| `BOT_SERVICE_TOKEN` | (required — bearer for adapter auth to `/api/v1/auth/telegram/bot`, looked up against `service_tokens`) |
|
||||
|
|
@ -1023,7 +1045,11 @@ pytest -m integration -q # integration, CI only
|
|||
|
||||
## 15. API surface (FastAPI)
|
||||
|
||||
All under `/api/v1` (mounted from day one). Three auth modes:
|
||||
JSON API routes live under `/api/v1`. The server-rendered **admin panel**
|
||||
is mounted at `/admin/*` and is the only HTML surface in the api; it reuses
|
||||
the user JWT for authentication (stored in an HttpOnly cookie).
|
||||
|
||||
Three JSON auth modes:
|
||||
|
||||
- **User JWT** (`Authorization: Bearer <jwt>`) — common token for bot users,
|
||||
Telegram Login Widget users, and Telegram Mini App users. Issued by
|
||||
|
|
@ -1046,6 +1072,11 @@ Health/metrics exempt from auth.
|
|||
| POST | `/api/v1/auth/telegram/bot` | service token | bot exchanges verified `telegram_id` for a user JWT |
|
||||
| POST | `/api/v1/auth/telegram/web` | — | verify Telegram Login Widget payload → issue user JWT |
|
||||
| POST | `/api/v1/auth/telegram/miniapp` | — | verify Mini App `initData` HMAC → issue user JWT |
|
||||
| POST | `/api/v1/auth/register` | — | email/password → new user + JWT pair (requires `WEB_AUTH_ENABLED`) |
|
||||
| POST | `/api/v1/auth/login` | — | email/password → JWT pair (requires `WEB_AUTH_ENABLED`) |
|
||||
| POST | `/api/v1/auth/logout` | refresh token | revoke refresh (requires `WEB_AUTH_ENABLED`) |
|
||||
| POST | `/api/v1/auth/forgot-password` | — | enqueue reset email (requires `WEB_AUTH_ENABLED`) |
|
||||
| POST | `/api/v1/auth/reset-password` | reset token | rotate password (requires `WEB_AUTH_ENABLED`) |
|
||||
| GET | `/api/v1/auth/me` | user JWT | introspect JWT claims |
|
||||
|
||||
### User endpoints (user JWT)
|
||||
|
|
@ -1072,6 +1103,30 @@ Health/metrics exempt from auth.
|
|||
| POST | `/api/v1/b2b/keys/{id}/revoke` | user JWT | revoke (instant auth disable) |
|
||||
| GET | `/api/v1/b2b/keys/{id}/usage` | user JWT | per-month request counts |
|
||||
|
||||
### Admin panel (`api/admin/`)
|
||||
|
||||
Mounted at `/admin/*`, gated by `WEB_ADMIN_ENABLED` and by the
|
||||
`ADMIN_REQUIRED_ROLE` check on `users.role`. Stack: FastAPI + Jinja2 + HTMX
|
||||
(client-side via CDN). Reuses the same access JWT, but stored in an
|
||||
HttpOnly cookie (`cc_admin_token`) so browsers can navigate it.
|
||||
|
||||
| Method | Path | Behavior |
|
||||
|---|---|---|
|
||||
| GET/POST | `/admin/login` | email/password → cookie; non-`admin` roles rejected |
|
||||
| POST | `/admin/logout` | clear cookie |
|
||||
| GET | `/admin` / `/admin/` | redirect to `/admin/users` if logged in, else `/admin/login` |
|
||||
| GET | `/admin/users` | paginated, searchable user list |
|
||||
| GET | `/admin/users/new` | create-user form |
|
||||
| POST | `/admin/users` | create a user (email, password, role, credits, optional telegram_id) |
|
||||
| GET | `/admin/users/{user_id}` | user detail + document stats |
|
||||
| POST | `/admin/users/{user_id}` | update role / active flag / credits |
|
||||
| POST | `/admin/users/{user_id}/toggle-active` | ban/unban (HTMX partial swap) |
|
||||
| GET | `/admin/users/{user_id}/credits` | bump credits by `delta` (HTMX partial swap) |
|
||||
|
||||
To grant admin access: set `users.role = 'admin'` (or the value of
|
||||
`ADMIN_REQUIRED_ROLE`) for the target account. The Makefile has helpers:
|
||||
`make admin-promote EMAIL=...` and `make admin-list`.
|
||||
|
||||
No synchronous `/analyze` (locked). Adapters poll `/reports/{id}`; the
|
||||
fine-grained `stage` field powers a progress signal in the bot ("Extracting
|
||||
text…", "Analyzing…"). SSE/webhook added later.
|
||||
|
|
|
|||
|
|
@ -105,7 +105,13 @@ TELEGRAM_BOT_TOKEN=$BOT_TOKEN # тот же токен; API испо
|
|||
BOT_SERVICE_TOKEN=bot-prod-secret-xxx # см. §5.3
|
||||
JWT_SECRET=$(openssl rand -hex 32) # HS256 secret для подписи JWT
|
||||
|
||||
# --- Postgres (можно оставить defaults для dev) ---
|
||||
# --- WebUI auth + admin panel (опционально, defaults ниже) ---
|
||||
WEB_AUTH_ENABLED=true # /api/v1/auth/register,login,...
|
||||
WEB_APP_BASE_URL=http://localhost:5173 # SPA — ссылки для сброса пароля
|
||||
WEB_ADMIN_ENABLED=true # панель управления по /admin
|
||||
ADMIN_REQUIRED_ROLE=admin # users.role, которое допущено в /admin
|
||||
PASSWORD_MIN_LENGTH=8
|
||||
|
||||
POSTGRES_USER=contract_check
|
||||
POSTGRES_PASSWORD=changeme-strong-password
|
||||
POSTGRES_DB=contract_check
|
||||
|
|
|
|||
39
migrations/versions/0004_user_roles.py
Normal file
39
migrations/versions/0004_user_roles.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""role column on users for the /admin panel (Stage 5).
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-08-13
|
||||
|
||||
Adds a non-null `role` column (default 'user') constrained to ('user','admin').
|
||||
The admin panel gate (settings.admin_required_role) matches this value. Existing
|
||||
rows back-fill to 'user' via the server_default; admin accounts are promoted
|
||||
manually (`UPDATE users SET role='admin' WHERE email=...`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0004"
|
||||
down_revision: str | None = "0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("role", sa.String(), nullable=False, server_default=sa.text("'user'")),
|
||||
)
|
||||
op.create_check_constraint("users_role_check", "users", "role IN ('user','admin')")
|
||||
op.create_index("users_role_idx", "users", ["role"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("users_role_idx", table_name="users")
|
||||
op.drop_constraint("users_role_check", "users", type_="check")
|
||||
op.drop_column("users", "role")
|
||||
70
migrations/versions/0005_tg_user_hardening.py
Normal file
70
migrations/versions/0005_tg_user_hardening.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Telegram bot user hardening: rate-limit, profile guards, binding audit.
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-08-13
|
||||
|
||||
Adds optional audit columns to users for Telegram-bound accounts and a
|
||||
verification flag so the admin panel can review anonymous/suspicious accounts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0005"
|
||||
down_revision: str | None = "0004"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Telegram profile snapshot captured on first /start (for admin review).
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"telegram_profile_json",
|
||||
sa.Text(),
|
||||
nullable=True,
|
||||
comment="Snapshot of Telegram first_name/last_name/username/language_code",
|
||||
),
|
||||
)
|
||||
|
||||
# True once the account is considered trusted. New auto-created Telegram
|
||||
# users may start as not_verified if their profile is empty.
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"telegram_verified",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("true"),
|
||||
comment="Telegram user passed profile guard; false = admin review",
|
||||
),
|
||||
)
|
||||
|
||||
# Audit timestamp for when a Telegram identity was first bound.
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"telegram_bound_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="When telegram_id was first set (auto-created or bound)",
|
||||
),
|
||||
)
|
||||
|
||||
op.create_index("users_telegram_verified_idx", "users", ["telegram_verified"])
|
||||
op.create_index("users_telegram_bound_idx", "users", ["telegram_bound_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("users_telegram_bound_idx", table_name="users")
|
||||
op.drop_index("users_telegram_verified_idx", table_name="users")
|
||||
op.drop_column("users", "telegram_bound_at")
|
||||
op.drop_column("users", "telegram_verified")
|
||||
op.drop_column("users", "telegram_profile_json")
|
||||
|
|
@ -67,6 +67,7 @@ api = [
|
|||
{ include-group = "obs" },
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.29",
|
||||
"jinja2>=3.1",
|
||||
"python-multipart>=0.0.9",
|
||||
"redis>=5.0",
|
||||
"pyjwt[crypto]>=2.8",
|
||||
|
|
@ -94,6 +95,7 @@ analyze = [
|
|||
]
|
||||
bot = [
|
||||
"aiogram>=3.4",
|
||||
"redis>=5.0",
|
||||
]
|
||||
notify = [
|
||||
{ include-group = "db" },
|
||||
|
|
|
|||
6
src/contract_check/api/admin/__init__.py
Normal file
6
src/contract_check/api/admin/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Server-rendered admin panel (FastAPI + Jinja2 + HTMX), mounted at /admin.
|
||||
|
||||
Reuses the api's auth (JWT + users.role) and DB session. The panel is a set of
|
||||
HTML routes for managing users (and, later, subscriptions). It is gated behind
|
||||
``WEB_ADMIN_ENABLED`` and the ``users.role = 'admin'`` check in ``auth.py``.
|
||||
"""
|
||||
120
src/contract_check/api/admin/auth.py
Normal file
120
src/contract_check/api/admin/auth.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Admin authentication: cookie/JWT session + role gate.
|
||||
|
||||
The panel authenticates operators by email+password (the existing web-auth
|
||||
flow) and stores the resulting access JWT in an HttpOnly cookie so the browser
|
||||
can drive HTMX navigation. The same JWT is also accepted via the
|
||||
``Authorization: Bearer`` header, so the panel can be scripted if needed.
|
||||
|
||||
Access requires ``users.role = 'admin'`` and ``is_active = true``. When the
|
||||
guard cannot establish an admin session it raises :class:`AdminAuthError`,
|
||||
which app.py maps to a redirect to ``/admin/login`` (bypassing the generic
|
||||
500 handler, which would otherwise swallow it).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.auth import AuthError, verify_access_token
|
||||
from ...core.config import get_settings
|
||||
from ..deps import AsyncSessionDep
|
||||
|
||||
# HttpOnly cookie carrying the admin access JWT.
|
||||
ADMIN_COOKIE = "cc_admin_token"
|
||||
# One day — matches the default JWT_ACCESS_TTL_MINUTES (1440).
|
||||
ADMIN_COOKIE_MAX_AGE = 24 * 3600
|
||||
|
||||
|
||||
class AdminAuthError(Exception):
|
||||
"""Raised to short-circuit a request into a redirect to ``/admin/login``."""
|
||||
|
||||
def __init__(self, reason: str = "auth") -> None:
|
||||
self.reason = reason
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminUser:
|
||||
"""The authenticated operator driving the admin panel."""
|
||||
|
||||
user_id: UUID
|
||||
telegram_id: int
|
||||
email: str | None
|
||||
role: str
|
||||
|
||||
|
||||
def _token_from_request(request: Request) -> str | None:
|
||||
cookie = request.cookies.get(ADMIN_COOKIE)
|
||||
if cookie:
|
||||
return cookie
|
||||
header = request.headers.get("authorization")
|
||||
if header and header.lower().startswith("bearer "):
|
||||
return header[7:].strip()
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_admin(request: Request, session: AsyncSession) -> AdminUser | None:
|
||||
"""Return the admin identity if the request carries a valid admin session.
|
||||
|
||||
Returns ``None`` for missing/expired tokens, unknown users, inactive
|
||||
accounts, or non-admin roles — the caller decides how to react.
|
||||
"""
|
||||
if not get_settings().web_admin_enabled:
|
||||
return None
|
||||
|
||||
token = _token_from_request(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
claims = verify_access_token(token)
|
||||
except AuthError:
|
||||
return None
|
||||
|
||||
result = await session.execute(
|
||||
text("SELECT id, telegram_id, email, role, is_active FROM users WHERE id = :u"),
|
||||
{"u": claims.sub},
|
||||
)
|
||||
row = result.first()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
user_id, telegram_id, email, role, is_active = row
|
||||
if not is_active or role != get_settings().admin_required_role:
|
||||
return None
|
||||
return AdminUser(
|
||||
user_id=user_id,
|
||||
telegram_id=int(telegram_id or 0),
|
||||
email=email,
|
||||
role=role,
|
||||
)
|
||||
|
||||
|
||||
async def require_admin(request: Request, session: AsyncSessionDep) -> AdminUser:
|
||||
"""Dependency: ensure an admin session exists, else redirect to login."""
|
||||
admin = await resolve_admin(request, session)
|
||||
if admin is None:
|
||||
raise AdminAuthError()
|
||||
return admin
|
||||
|
||||
|
||||
AdminUserDep = AdminUser
|
||||
|
||||
|
||||
def require_htmx(request: Request) -> None:
|
||||
"""Dependency: only accept mutating requests coming from HTMX.
|
||||
|
||||
HTMX sends ``HX-Request: true`` on every request; a cross-site HTML form
|
||||
cannot set a custom header without triggering a CORS preflight, so this is
|
||||
an effective CSRF defence for cookie-authed, htmx-driven forms.
|
||||
"""
|
||||
if request.headers.get("hx-request") != "true":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="htmx request required")
|
||||
|
||||
|
||||
HtmxGuard = Depends(require_htmx)
|
||||
124
src/contract_check/api/admin/router.py
Normal file
124
src/contract_check/api/admin/router.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Parent admin router: index redirect + login/logout.
|
||||
|
||||
Authentication reuses the webUI email/password flow (argon2 verify) and the
|
||||
existing JWT issuer, then sets an HttpOnly cookie. Only ``role = 'admin'``
|
||||
accounts may log in here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Form, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy import text
|
||||
|
||||
from ...core.auth import create_access_token
|
||||
from ...core.config import get_settings
|
||||
from ...core.logging import get_logger
|
||||
from ...core.security.passwords import verify_password
|
||||
from ..deps import AsyncSessionDep
|
||||
from .auth import ADMIN_COOKIE, ADMIN_COOKIE_MAX_AGE, resolve_admin
|
||||
from .templating import templates
|
||||
from .users import router as users_router
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
router = APIRouter(tags=["admin"])
|
||||
router.include_router(users_router)
|
||||
|
||||
|
||||
@router.get("/admin", include_in_schema=False)
|
||||
@router.get("/admin/", include_in_schema=False)
|
||||
async def index(
|
||||
request: Request,
|
||||
session: AsyncSessionDep,
|
||||
) -> RedirectResponse:
|
||||
"""Authenticated entry point → users list; otherwise → login."""
|
||||
admin = await resolve_admin(request, session)
|
||||
target = "/admin/users" if admin is not None else "/admin/login"
|
||||
return RedirectResponse(url=target, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/login",
|
||||
response_class=HTMLResponse,
|
||||
response_model=None,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def login_form(
|
||||
request: Request,
|
||||
session: AsyncSessionDep,
|
||||
reason: str = "",
|
||||
) -> HTMLResponse | RedirectResponse:
|
||||
# Already logged in as admin → skip the form.
|
||||
if await resolve_admin(request, session) is not None:
|
||||
return RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER)
|
||||
messages = {
|
||||
"invalid": "Неверный email или пароль.",
|
||||
"forbidden": "Учётная запись не имеет прав администратора.",
|
||||
"disabled": "Учётная запись отключена.",
|
||||
"auth": "Сессия истекла — войдите снова.",
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{"request": request, "error": messages.get(reason, "")},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/login", include_in_schema=False)
|
||||
async def login(
|
||||
session: AsyncSessionDep,
|
||||
email: Annotated[str, Form()],
|
||||
password: Annotated[str, Form()],
|
||||
) -> RedirectResponse:
|
||||
"""Verify email/password + admin role, set the cookie, redirect to users."""
|
||||
email_norm = email.lower().strip()
|
||||
result = await session.execute(
|
||||
text(
|
||||
"SELECT id, telegram_id, email, password_hash, role, is_active "
|
||||
"FROM users WHERE email = :e"
|
||||
),
|
||||
{"e": email_norm},
|
||||
)
|
||||
row = result.first()
|
||||
invalid = RedirectResponse(
|
||||
url="/admin/login?reason=invalid", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
if row is None:
|
||||
return invalid
|
||||
|
||||
user_id, telegram_id, _email, password_hash, role, is_active = row
|
||||
if not password_hash or not verify_password(password, password_hash):
|
||||
return invalid
|
||||
if not is_active:
|
||||
return RedirectResponse(
|
||||
url="/admin/login?reason=disabled", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
if role != get_settings().admin_required_role:
|
||||
log.warning("admin_login_forbidden", user_id=str(user_id), role=role)
|
||||
return RedirectResponse(
|
||||
url="/admin/login?reason=forbidden", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
token = create_access_token(user_id, int(telegram_id or 0))
|
||||
response = RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.set_cookie(
|
||||
key=ADMIN_COOKIE,
|
||||
value=token,
|
||||
max_age=ADMIN_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=get_settings().env == "prod",
|
||||
)
|
||||
log.info("admin_logged_in", user_id=str(user_id))
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/admin/logout", include_in_schema=False)
|
||||
async def logout() -> RedirectResponse:
|
||||
"""Clear the admin cookie and return to the login page."""
|
||||
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(ADMIN_COOKIE)
|
||||
return response
|
||||
70
src/contract_check/api/admin/templates/_user_card.html
Normal file
70
src/contract_check/api/admin/templates/_user_card.html
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
{# Partial swapped by HTMX after updates. Depends on `user`, `stats`, `roles`. #}
|
||||
<div class="grid cols-2">
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">
|
||||
{{ user.email or 'Без email' }}
|
||||
{% if user.telegram_id %}<span class="muted" style="font-weight:400">· tg {{ user.telegram_id }}</span>{% endif %}
|
||||
</h2>
|
||||
<p class="muted" style="margin:0 0 14px">ID: <code>{{ user.id }}</code></p>
|
||||
<p class="muted" style="margin:0 0 14px">
|
||||
{% if user.telegram_verified %}
|
||||
<span class="pill on">TG проверен</span>
|
||||
{% else %}
|
||||
<span class="pill warn">TG на проверке</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="grid cols-2" style="margin-bottom:6px">
|
||||
<div><div class="stat">{{ user.credits_left }}</div><div class="stat-label">Кредиты</div></div>
|
||||
<div><div class="stat">{{ stats.docs_total }}</div><div class="stat-label">Документов</div></div>
|
||||
<div><div class="stat" style="color:var(--ok)">{{ stats.docs_done }}</div><div class="stat-label">Успешных</div></div>
|
||||
<div><div class="stat" style="color:var(--bad)">{{ stats.docs_failed }}</div><div class="stat-label">Ошибок</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form hx-post="/admin/users/{{ user.id }}" hx-target="#user-card" hx-swap="outerHTML">
|
||||
<label for="role">Роль</label>
|
||||
<select id="role" name="role">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}" {{ 'selected' if r == user.role else '' }}>{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="credits_left">Кредиты</label>
|
||||
<div class="row">
|
||||
<input id="credits_left" name="credits_left" type="number" min="0" value="{{ user.credits_left }}" style="width:140px">
|
||||
<a class="btn ghost sm" hx-get="/admin/users/{{ user.id }}/credits?delta=10" hx-target="#user-card" hx-swap="outerHTML">+10</a>
|
||||
<a class="btn ghost sm" hx-get="/admin/users/{{ user.id }}/credits?delta=-1" hx-target="#user-card" hx-swap="outerHTML">−1</a>
|
||||
<a class="btn ghost sm" hx-get="/admin/users/{{ user.id }}/credits?delta=100" hx-target="#user-card" hx-swap="outerHTML">+100</a>
|
||||
</div>
|
||||
|
||||
<label class="row" style="display:flex;align-items:center;gap:8px;margin-top:16px">
|
||||
<input type="checkbox" name="is_active" style="width:auto"
|
||||
{{ 'checked' if user.is_active else '' }}>
|
||||
<span style="color:var(--ink);font-size:14px">Аккаунт активен</span>
|
||||
</label>
|
||||
|
||||
<div class="row" style="margin-top:18px">
|
||||
<button class="btn" type="submit">Сохранить</button>
|
||||
{% if user.is_active %}
|
||||
<button class="btn danger" type="button"
|
||||
hx-post="/admin/users/{{ user.id }}/toggle-active" hx-target="#user-card" hx-swap="outerHTML">
|
||||
Заблокировать
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn" type="button"
|
||||
hx-post="/admin/users/{{ user.id }}/toggle-active" hx-target="#user-card" hx-swap="outerHTML">
|
||||
Разблокировать
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if not user.telegram_verified %}
|
||||
<button class="btn" type="button"
|
||||
hx-post="/admin/users/{{ user.id }}/verify-telegram" hx-target="#user-card" hx-swap="outerHTML">
|
||||
Подтвердить TG
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
90
src/contract_check/api/admin/templates/base.html
Normal file
90
src/contract_check/api/admin/templates/base.html
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Админка — Контракт-чек{% endblock %}</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3" defer></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#0f1115; --panel:#171a21; --ink:#e6e8ec; --muted:#9aa3b2;
|
||||
--accent:#4f8cff; --accent-d:#3b73e6; --ok:#3ecf8e; --warn:#f5a524;
|
||||
--bad:#f24e6e; --line:#262a33; --radius:10px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);
|
||||
font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
header{display:flex;align-items:center;gap:18px;padding:14px 22px;
|
||||
background:var(--panel);border-bottom:1px solid var(--line)}
|
||||
header .brand{font-weight:700;letter-spacing:.3px}
|
||||
header nav{display:flex;gap:16px;align-items:center}
|
||||
header nav a{color:var(--muted)}
|
||||
header nav a.active{color:var(--ink)}
|
||||
header .spacer{flex:1}
|
||||
main{max-width:1080px;margin:26px auto;padding:0 22px}
|
||||
.card{background:var(--panel);border:1px solid var(--line);
|
||||
border-radius:var(--radius);padding:20px 22px;margin-bottom:18px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line)}
|
||||
th{color:var(--muted);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.5px}
|
||||
tr:hover td{background:#1b1f28}
|
||||
.pill{display:inline-block;padding:2px 9px;border-radius:999px;font-size:12px;font-weight:600}
|
||||
.pill.user{background:#243043;color:#9cc3ff}
|
||||
.pill.admin{background:#1f4d39;color:#7ff0bd}
|
||||
.pill.on{background:#1f4d39;color:#7ff0bd}
|
||||
.pill.off{background:#4a2330;color:#ffb3c2}
|
||||
.grid{display:grid;gap:18px}
|
||||
.grid.cols-2{grid-template-columns:1fr 1fr}
|
||||
@media(max-width:760px){.grid.cols-2{grid-template-columns:1fr}}
|
||||
label{display:block;color:var(--muted);font-size:12px;margin:10px 0 4px}
|
||||
input,select{width:100%;background:#0f1115;color:var(--ink);
|
||||
border:1px solid var(--line);border-radius:8px;padding:9px 11px}
|
||||
input:focus,select:focus{outline:none;border-color:var(--accent)}
|
||||
.btn{display:inline-block;background:var(--accent);color:#fff;border:0;
|
||||
border-radius:8px;padding:9px 15px;font-weight:600;cursor:pointer}
|
||||
.btn:hover{background:var(--accent-d);text-decoration:none}
|
||||
.btn.ghost{background:transparent;border:1px solid var(--line);color:var(--ink)}
|
||||
.btn.ghost:hover{border-color:var(--accent)}
|
||||
.btn.danger{background:var(--bad)}
|
||||
.btn.sm{padding:5px 10px;font-size:12px}
|
||||
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||
.muted{color:var(--muted)}
|
||||
.stat{font-size:26px;font-weight:700}
|
||||
.stat-label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.5px}
|
||||
.toolbar{display:flex;gap:12px;align-items:center;margin-bottom:16px;flex-wrap:wrap}
|
||||
#toast{position:fixed;bottom:22px;right:22px;background:var(--ok);color:#04130c;
|
||||
padding:11px 16px;border-radius:8px;font-weight:600;opacity:0;
|
||||
transform:translateY(10px);transition:.2s;pointer-events:none;z-index:50}
|
||||
#toast.show{opacity:1;transform:none}
|
||||
.pager{display:flex;gap:6px;justify-content:center;margin-top:18px}
|
||||
.empty{color:var(--muted);text-align:center;padding:40px}
|
||||
code{background:#0f1115;padding:1px 6px;border-radius:4px;color:var(--muted)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% block header %}
|
||||
<header>
|
||||
<span class="brand">⚙ Контракт-чек · Admin</span>
|
||||
<nav>
|
||||
<a href="/admin/users" class="{{ 'active' if request.url.path.startswith('/admin/users') else '' }}">Пользователи</a>
|
||||
</nav>
|
||||
<span class="spacer"></span>
|
||||
<form method="post" action="/admin/logout"><button class="btn ghost sm" type="submit">Выйти</button></form>
|
||||
</header>
|
||||
{% endblock %}
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<div id="toast"></div>
|
||||
<script>
|
||||
document.body.addEventListener('showToast', function (e) {
|
||||
var t = document.getElementById('toast');
|
||||
t.textContent = e.detail.value || e.detail;
|
||||
t.classList.add('show');
|
||||
setTimeout(function () { t.classList.remove('show'); }, 2200);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
19
src/contract_check/api/admin/templates/login.html
Normal file
19
src/contract_check/api/admin/templates/login.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Вход — Админка{% endblock %}
|
||||
{% block header %}{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width:380px;margin:8vh auto">
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Вход в админку</h2>
|
||||
<p class="muted" style="margin-top:0">Только для учётных записей с ролью <code>admin</code>.</p>
|
||||
{% if error %}<p style="color:var(--bad);margin:0 0 12px">{{ error }}</p>{% endif %}
|
||||
<form method="post" action="/admin/login">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" required autocomplete="email" autofocus>
|
||||
<label for="password">Пароль</label>
|
||||
<input id="password" name="password" type="password" required autocomplete="current-password">
|
||||
<button class="btn" type="submit" style="width:100%;margin-top:16px">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
11
src/contract_check/api/admin/templates/users_detail.html
Normal file
11
src/contract_check/api/admin/templates/users_detail.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ user.email or user.telegram_id or user.id }} — Админка{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row" style="margin-bottom:18px">
|
||||
<a class="muted" href="/admin/users">← все пользователи</a>
|
||||
</div>
|
||||
|
||||
<div id="user-card">
|
||||
{% include "_user_card.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
68
src/contract_check/api/admin/templates/users_list.html
Normal file
68
src/contract_check/api/admin/templates/users_list.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Пользователи — Админка{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<h2 style="margin:0">Пользователи <span class="muted" style="font-weight:400;font-size:14px">({{ total }})</span></h2>
|
||||
<span class="spacer"></span>
|
||||
<a class="btn sm {{ 'secondary' if not only_telegram else '' }}" href="/admin/users?only_telegram=1&unverified={{ '1' if unverified else '0' }}">Только TG</a>
|
||||
<a class="btn sm {{ 'secondary' if not unverified else '' }}" href="/admin/users?unverified=1&only_telegram={{ '1' if only_telegram else '0' }}">На проверке</a>
|
||||
<a class="btn sm" href="/admin/users/new">+ Создать</a>
|
||||
<form method="get" action="/admin/users" hx-get="/admin/users"
|
||||
hx-target="#user-table" hx-select="tbody" hx-swap="outerHTML"
|
||||
hx-trigger="submit, keyup delay:400ms from:find input[name=q]">
|
||||
<div class="row">
|
||||
<input type="hidden" name="only_telegram" value="{{ '1' if only_telegram else '0' }}">
|
||||
<input type="hidden" name="unverified" value="{{ '1' if unverified else '0' }}">
|
||||
<input name="q" value="{{ q }}" placeholder="Поиск по email или telegram_id" style="width:280px">
|
||||
<button class="btn ghost sm" type="submit">Найти</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email / Telegram</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>TG проверен</th>
|
||||
<th>Кредиты</th>
|
||||
<th>Создан</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-table">
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ u.email or '—' }}
|
||||
{% if u.telegram_id %}<div class="muted">tg: {{ u.telegram_id }}</div>{% endif %}
|
||||
</td>
|
||||
<td><span class="pill {{ u.role }}">{{ u.role }}</span></td>
|
||||
<td>
|
||||
{% if u.is_active %}<span class="pill on">active</span>{% else %}<span class="pill off">blocked</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.telegram_verified %}<span class="pill on">да</span>{% else %}<span class="pill warn">на проверке</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ u.credits_left }}</td>
|
||||
<td class="muted">{{ u.created_at | dt }}</td>
|
||||
<td><a class="btn ghost sm" href="/admin/users/{{ u.id }}">Открыть</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7"><div class="empty">Ничего не найдено</div></td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if pages > 1 %}
|
||||
<div class="pager">
|
||||
{% for p in range(1, pages + 1) %}
|
||||
{% if p == page %}<span class="btn sm" style="background:var(--line);cursor:default">{{ p }}</span>
|
||||
{% else %}<a class="btn ghost sm" href="/admin/users?q={{ q }}&page={{ p }}">{{ p }}</a>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
51
src/contract_check/api/admin/templates/users_new.html
Normal file
51
src/contract_check/api/admin/templates/users_new.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Новый пользователь — Админка{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row" style="margin-bottom:18px">
|
||||
<a class="muted" href="/admin/users">← все пользователи</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width:560px">
|
||||
<h2 style="margin-top:0">Новый пользователь</h2>
|
||||
<p class="muted" style="margin-top:0">Email/пароль с выбираемой ролью и стартовыми кредитами.</p>
|
||||
{% if error %}<p style="color:var(--bad);margin:0 0 14px">{{ error }}</p>{% endif %}
|
||||
<form method="post" action="/admin/users">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" required autocomplete="off"
|
||||
value="{{ email }}">
|
||||
|
||||
<label for="password">Пароль <span class="muted">(мин. {{ min_password_length }} симв.)</span></label>
|
||||
<input id="password" name="password" type="password" required
|
||||
minlength="{{ min_password_length }}" autocomplete="new-password">
|
||||
|
||||
<label for="role">Роль</label>
|
||||
<select id="role" name="role">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}" {{ 'selected' if r == role else '' }}>{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<div class="grid cols-2">
|
||||
<div>
|
||||
<label for="credits_left">Стартовые кредиты</label>
|
||||
<input id="credits_left" name="credits_left" type="number" min="0" value="{{ credits_left }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="telegram_id">Telegram ID <span class="muted">(необязательно)</span></label>
|
||||
<input id="telegram_id" name="telegram_id" type="number" min="1"
|
||||
placeholder="—" value="{{ telegram_id }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="row" style="display:flex;align-items:center;gap:8px;margin-top:16px">
|
||||
<input type="checkbox" name="is_active" style="width:auto" {{ 'checked' if is_active else '' }}>
|
||||
<span style="color:var(--ink);font-size:14px">Аккаунт активен</span>
|
||||
</label>
|
||||
|
||||
<div class="row" style="margin-top:18px">
|
||||
<button class="btn" type="submit">Создать</button>
|
||||
<a class="btn ghost" href="/admin/users">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
28
src/contract_check/api/admin/templating.py
Normal file
28
src/contract_check/api/admin/templating.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Jinja2 template engine for the admin panel.
|
||||
|
||||
Templates ship inside the package (``src/contract_check/api/admin/templates``)
|
||||
so the Docker image picks them up via the wheel install. A ``dt`` filter keeps
|
||||
datetime rendering consistent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
||||
|
||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||
|
||||
|
||||
def _format_dt(value: object, fmt: str = "%Y-%m-%d %H:%M UTC") -> str:
|
||||
if isinstance(value, dt.datetime):
|
||||
return value.strftime(fmt)
|
||||
if value is None:
|
||||
return "—"
|
||||
return str(value)
|
||||
|
||||
|
||||
templates.env.filters["dt"] = _format_dt
|
||||
422
src/contract_check/api/admin/users.py
Normal file
422
src/contract_check/api/admin/users.py
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
"""User management routes for the admin panel.
|
||||
|
||||
List / search / create / detail / edit (role, is_active, credits) over the
|
||||
``users`` table. Edit routes are HTMX-driven and swap a single ``#user-card``
|
||||
partial so the UI updates without a full reload; create is a full-page form
|
||||
that redirects to the new user's detail page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.config import get_settings
|
||||
from ...core.db.enums import USER_ROLE_USER, USER_ROLES
|
||||
from ...core.logging import get_logger
|
||||
from ...core.security.passwords import hash_password
|
||||
from ..deps import AsyncSessionDep
|
||||
from .auth import HtmxGuard, require_admin
|
||||
from .templating import templates
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/users",
|
||||
tags=["admin-users"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
PAGE_SIZE = 25
|
||||
|
||||
_USER_COLUMNS = (
|
||||
"id, telegram_id, email, role, is_active, credits_left, created_at, telegram_verified"
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_user(session: AsyncSession, user_id: uuid.UUID) -> dict[str, Any] | None:
|
||||
result = await session.execute(
|
||||
text(f"SELECT {_USER_COLUMNS} FROM users WHERE id = :u"),
|
||||
{"u": user_id},
|
||||
)
|
||||
row = result.first()
|
||||
if row is None:
|
||||
return None
|
||||
return _user_row_to_dict(row)
|
||||
|
||||
|
||||
async def _fetch_user_stats(session: AsyncSession, user_id: uuid.UUID) -> dict[str, int]:
|
||||
"""Aggregate counts shown on the detail page."""
|
||||
result = await session.execute(
|
||||
text(
|
||||
"SELECT count(*), "
|
||||
" count(*) FILTER (WHERE status = 'done'), "
|
||||
" count(*) FILTER (WHERE status = 'failed') "
|
||||
"FROM documents WHERE user_id = :u"
|
||||
),
|
||||
{"u": user_id},
|
||||
)
|
||||
row = result.first()
|
||||
if row is None:
|
||||
return {"docs_total": 0, "docs_done": 0, "docs_failed": 0}
|
||||
return {
|
||||
"docs_total": int(row[0] or 0),
|
||||
"docs_done": int(row[1] or 0),
|
||||
"docs_failed": int(row[2] or 0),
|
||||
}
|
||||
|
||||
|
||||
def _user_row_to_dict(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row[0],
|
||||
"telegram_id": row[1],
|
||||
"email": row[2],
|
||||
"role": row[3],
|
||||
"is_active": bool(row[4]),
|
||||
"credits_left": int(row[5]),
|
||||
"created_at": row[6],
|
||||
"telegram_verified": bool(row[7]),
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def list_users(
|
||||
request: Request,
|
||||
session: AsyncSessionDep,
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
) -> HTMLResponse:
|
||||
"""Paginated, searchable user list."""
|
||||
page = max(page, 1)
|
||||
offset = (page - 1) * PAGE_SIZE
|
||||
term = q.strip()
|
||||
filter_sql = ""
|
||||
filter_params: dict[str, Any] = {}
|
||||
|
||||
# Optional admin filters via query string (no URL params in form; keep simple).
|
||||
only_telegram = request.query_params.get("only_telegram") == "1"
|
||||
unverified = request.query_params.get("unverified") == "1"
|
||||
|
||||
if only_telegram:
|
||||
filter_sql += " AND telegram_id IS NOT NULL"
|
||||
if unverified:
|
||||
filter_sql += " AND telegram_verified = FALSE"
|
||||
|
||||
base_where = "WHERE (email ILIKE :q OR telegram_id::text ILIKE :q)" if term else "WHERE TRUE"
|
||||
base_where += filter_sql
|
||||
like = f"%{term}%"
|
||||
filter_params = {"q": like} if term else {}
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
f"SELECT {_USER_COLUMNS} FROM users "
|
||||
f"{base_where} "
|
||||
"ORDER BY created_at DESC LIMIT :lim OFFSET :off"
|
||||
),
|
||||
{**filter_params, "lim": PAGE_SIZE, "off": offset},
|
||||
)
|
||||
).all()
|
||||
total = int(
|
||||
(
|
||||
await session.execute(
|
||||
text(f"SELECT count(*) FROM users {base_where}"),
|
||||
filter_params,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
users = [_user_row_to_dict(r) for r in rows]
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"users_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"users": users,
|
||||
"q": term,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"total": total,
|
||||
"roles": USER_ROLES,
|
||||
"only_telegram": only_telegram,
|
||||
"unverified": unverified,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Crude but sufficient email shape check — the DB UNIQUE constraint and the
|
||||
# register route's EmailStr are the authoritative validators.
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _render_new_form(
|
||||
request: Request,
|
||||
error: str = "",
|
||||
email: str = "",
|
||||
role: str = USER_ROLE_USER,
|
||||
credits_left: int = 0,
|
||||
telegram_id: str = "",
|
||||
is_active: bool = True,
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"users_new.html",
|
||||
{
|
||||
"request": request,
|
||||
"error": error,
|
||||
"email": email,
|
||||
"role": role,
|
||||
"credits_left": credits_left,
|
||||
"telegram_id": telegram_id,
|
||||
"is_active": is_active,
|
||||
"roles": USER_ROLES,
|
||||
"min_password_length": get_settings().password_min_length,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/new", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def new_user_form(request: Request) -> HTMLResponse:
|
||||
"""Empty create-user form."""
|
||||
return _render_new_form(request)
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse, response_model=None, include_in_schema=False)
|
||||
async def create_user(
|
||||
request: Request,
|
||||
session: AsyncSessionDep,
|
||||
email: Annotated[str, Form()],
|
||||
password: Annotated[str, Form()],
|
||||
role: Annotated[str, Form()] = USER_ROLE_USER,
|
||||
credits_left: Annotated[int, Form()] = 0,
|
||||
telegram_id: Annotated[str, Form()] = "",
|
||||
is_active: Annotated[str, Form()] = "on",
|
||||
) -> HTMLResponse | RedirectResponse:
|
||||
"""Create an email/password user with an explicit role + starting credits.
|
||||
|
||||
Mirrors the public register route's conventions (email normalization, argon2
|
||||
hash, min-length gate, duplicate-email check) and additionally lets the
|
||||
operator pick the role, grant credits, and optionally link a Telegram id.
|
||||
"""
|
||||
email_norm = email.lower().strip()
|
||||
tg = telegram_id.strip()
|
||||
|
||||
# ── validation ──
|
||||
if not _EMAIL_RE.match(email_norm):
|
||||
return _render_new_form(request, "Некорректный email.", email_norm, role, credits_left, tg)
|
||||
min_len = get_settings().password_min_length
|
||||
if len(password) < min_len:
|
||||
return _render_new_form(
|
||||
request, f"Пароль короче {min_len} символов.", email_norm, role, credits_left, tg
|
||||
)
|
||||
if role not in USER_ROLES:
|
||||
return _render_new_form(request, "Недопустимая роль.", email_norm, role, credits_left, tg)
|
||||
credits = max(0, int(credits_left))
|
||||
tg_id: int | None = None
|
||||
if tg:
|
||||
try:
|
||||
tg_id = int(tg)
|
||||
if tg_id <= 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
return _render_new_form(
|
||||
request,
|
||||
"Telegram ID должен быть положительным числом.",
|
||||
email_norm,
|
||||
role,
|
||||
credits_left,
|
||||
tg,
|
||||
)
|
||||
|
||||
dup = (
|
||||
await session.execute(text("SELECT 1 FROM users WHERE email = :e"), {"e": email_norm})
|
||||
).first()
|
||||
if dup is not None:
|
||||
return _render_new_form(
|
||||
request,
|
||||
"Пользователь с таким email уже существует.",
|
||||
email_norm,
|
||||
role,
|
||||
credits_left,
|
||||
tg,
|
||||
)
|
||||
if tg_id is not None:
|
||||
dup_tg = (
|
||||
await session.execute(text("SELECT 1 FROM users WHERE telegram_id = :t"), {"t": tg_id})
|
||||
).first()
|
||||
if dup_tg is not None:
|
||||
return _render_new_form(
|
||||
request,
|
||||
"Этот Telegram ID уже привязан к другому пользователю.",
|
||||
email_norm,
|
||||
role,
|
||||
credits_left,
|
||||
tg,
|
||||
)
|
||||
|
||||
# ── insert ──
|
||||
hashed = hash_password(password)
|
||||
result = await session.execute(
|
||||
text(
|
||||
"INSERT INTO users "
|
||||
"(email, password_hash, role, credits_left, telegram_id, is_active) "
|
||||
"VALUES (:e, :p, :r, :c, :t, :a) "
|
||||
f"RETURNING {_USER_COLUMNS}"
|
||||
),
|
||||
{
|
||||
"e": email_norm,
|
||||
"p": hashed,
|
||||
"r": role,
|
||||
"c": credits,
|
||||
"t": tg_id,
|
||||
"a": is_active == "on",
|
||||
},
|
||||
)
|
||||
row = result.first()
|
||||
assert row is not None # RETURNING always yields the inserted row
|
||||
await session.commit()
|
||||
new_id = row[0]
|
||||
log.info("admin_user_created", user_id=str(new_id), email=email_norm, role=role)
|
||||
return RedirectResponse(url=f"/admin/users/{new_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def user_detail(
|
||||
user_id: uuid.UUID,
|
||||
request: Request,
|
||||
session: AsyncSessionDep,
|
||||
) -> HTMLResponse:
|
||||
user = await _fetch_user(session, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found")
|
||||
stats = await _fetch_user_stats(session, user_id)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"users_detail.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user,
|
||||
"stats": stats,
|
||||
"roles": USER_ROLES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{user_id}", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def update_user(
|
||||
user_id: uuid.UUID,
|
||||
request: Request,
|
||||
session: AsyncSessionDep,
|
||||
_: Annotated[None, HtmxGuard],
|
||||
role: Annotated[str, Form()],
|
||||
is_active: Annotated[str, Form()] = "",
|
||||
credits_left: Annotated[int, Form()] = 0,
|
||||
) -> HTMLResponse:
|
||||
"""Update role / active flag / credits. Returns the refreshed user card."""
|
||||
if role not in USER_ROLES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid role")
|
||||
credits = max(0, int(credits_left))
|
||||
await session.execute(
|
||||
text("UPDATE users SET role = :r, is_active = :a, credits_left = :c WHERE id = :u"),
|
||||
{"r": role, "a": is_active == "on", "c": credits, "u": user_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
user = await _fetch_user(session, user_id)
|
||||
assert user is not None # just updated this row
|
||||
stats = await _fetch_user_stats(session, user_id)
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"_user_card.html",
|
||||
{"request": request, "user": user, "stats": stats, "roles": USER_ROLES},
|
||||
)
|
||||
response.headers["HX-Trigger"] = json.dumps({"showToast": "Пользователь обновлён"})
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{user_id}/toggle-active", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def toggle_active(
|
||||
user_id: uuid.UUID,
|
||||
request: Request,
|
||||
session: AsyncSessionDep,
|
||||
_: Annotated[None, HtmxGuard],
|
||||
) -> HTMLResponse:
|
||||
await session.execute(
|
||||
text("UPDATE users SET is_active = NOT is_active WHERE id = :u"),
|
||||
{"u": user_id},
|
||||
)
|
||||
await session.commit()
|
||||
user = await _fetch_user(session, user_id)
|
||||
assert user is not None
|
||||
stats = await _fetch_user_stats(session, user_id)
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"_user_card.html",
|
||||
{"request": request, "user": user, "stats": stats, "roles": USER_ROLES},
|
||||
)
|
||||
msg = "Пользователь разблокирован" if user["is_active"] else "Пользователь заблокирован"
|
||||
response.headers["HX-Trigger"] = json.dumps({"showToast": msg})
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{user_id}/verify-telegram", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def verify_telegram(
|
||||
user_id: uuid.UUID,
|
||||
request: Request,
|
||||
session: AsyncSessionDep,
|
||||
_: Annotated[None, HtmxGuard],
|
||||
) -> HTMLResponse:
|
||||
"""Mark the user's Telegram identity as verified (admin review)."""
|
||||
await session.execute(
|
||||
text("UPDATE users SET telegram_verified = TRUE WHERE id = :u"),
|
||||
{"u": user_id},
|
||||
)
|
||||
await session.commit()
|
||||
user = await _fetch_user(session, user_id)
|
||||
assert user is not None
|
||||
stats = await _fetch_user_stats(session, user_id)
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"_user_card.html",
|
||||
{"request": request, "user": user, "stats": stats, "roles": USER_ROLES},
|
||||
)
|
||||
response.headers["HX-Trigger"] = json.dumps({"showToast": "Telegram-профиль подтверждён"})
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/{user_id}/credits", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def adjust_credits(
|
||||
user_id: uuid.UUID,
|
||||
request: Request,
|
||||
session: AsyncSessionDep,
|
||||
delta: int = 0,
|
||||
) -> HTMLResponse:
|
||||
"""Bump credits by ``delta`` (clamped at 0). HTMX button target."""
|
||||
if delta != 0:
|
||||
await session.execute(
|
||||
text("UPDATE users SET credits_left = greatest(0, credits_left + :d) WHERE id = :u"),
|
||||
{"d": int(delta), "u": user_id},
|
||||
)
|
||||
await session.commit()
|
||||
user = await _fetch_user(session, user_id)
|
||||
assert user is not None
|
||||
stats = await _fetch_user_stats(session, user_id)
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"_user_card.html",
|
||||
{"request": request, "user": user, "stats": stats, "roles": USER_ROLES},
|
||||
)
|
||||
if delta != 0:
|
||||
sign = "+" if delta > 0 else ""
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"showToast": f"Кредиты изменены на {sign}{delta}"}
|
||||
)
|
||||
return response
|
||||
|
|
@ -9,6 +9,7 @@ from contextlib import asynccontextmanager
|
|||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from ..core.config import get_settings
|
||||
from ..core.llm import port as llm_port # noqa: F401 — package loaded
|
||||
|
|
@ -21,6 +22,9 @@ from ..core.redis_client import get_redis_client
|
|||
from ..core.s3.minio_storage import MinioStorage
|
||||
from ..core.sentry import init_sentry
|
||||
from ..core.telemetry import setup_telemetry, shutdown_telemetry
|
||||
from .admin.auth import AdminAuthError
|
||||
from .admin.router import router as admin_router
|
||||
from .deps import create_email_user
|
||||
from .middleware import add_middleware
|
||||
from .routes import auth, b2b, documents, health, me, metrics, reports
|
||||
|
||||
|
|
@ -77,6 +81,11 @@ async def lifespan(app: FastAPI) -> Any:
|
|||
app.state.rate_limiter = rate_limiter
|
||||
app.state.redis = redis_client
|
||||
|
||||
# Ensure a default admin account exists if ADMIN_DEFAULT_PASSWORD is set.
|
||||
# This runs on every startup but is a no-op once the account exists.
|
||||
if settings.web_admin_enabled and settings.admin_default_password:
|
||||
await _ensure_default_admin(settings, redis_client)
|
||||
|
||||
yield
|
||||
|
||||
await publisher.close()
|
||||
|
|
@ -104,4 +113,58 @@ def create_app() -> FastAPI:
|
|||
app.include_router(reports.router)
|
||||
app.include_router(me.router)
|
||||
app.include_router(b2b.router)
|
||||
|
||||
# Server-rendered admin panel (FastAPI + Jinja2 + HTMX). Gated behind the
|
||||
# WEB_ADMIN_ENABLED flag; routes additionally require users.role = 'admin'.
|
||||
if get_settings().web_admin_enabled:
|
||||
app.include_router(admin_router)
|
||||
|
||||
# AdminAuthError → browser redirect to /admin/login (the generic 500
|
||||
# handler would otherwise swallow this Exception subclass).
|
||||
@app.exception_handler(AdminAuthError)
|
||||
async def _admin_auth_redirect( # noqa: RUF0 — intentional closure
|
||||
_request: Any, exc: AdminAuthError
|
||||
) -> RedirectResponse:
|
||||
return RedirectResponse(url=f"/admin/login?reason={exc.reason}", status_code=303)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _ensure_default_admin(settings: Any, redis_client: Any) -> None:
|
||||
"""Create the default admin account if no user with the admin email exists.
|
||||
|
||||
Uses a one-off DB session because the FastAPI dependency chain is not
|
||||
available during lifespan setup. Requires web auth (Redis) to be available.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..core.db.session import create_session_factory
|
||||
from ..core.security.passwords import hash_password
|
||||
|
||||
logger = get_logger(__name__)
|
||||
if redis_client is None:
|
||||
logger.warning("default_admin_skipped", reason="redis unavailable")
|
||||
return
|
||||
|
||||
factory = create_session_factory()
|
||||
async with factory() as session:
|
||||
existing = await session.execute(
|
||||
text("SELECT id FROM users WHERE email = :e"),
|
||||
{"e": settings.admin_default_email.lower().strip()},
|
||||
)
|
||||
if existing.first() is None:
|
||||
user = await create_email_user(
|
||||
session,
|
||||
email=settings.admin_default_email.lower().strip(),
|
||||
password_hash=hash_password(settings.admin_default_password),
|
||||
)
|
||||
await session.execute(
|
||||
text("UPDATE users SET role = :r WHERE id = :u"),
|
||||
{"r": settings.admin_required_role, "u": user.id},
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"default_admin_created",
|
||||
user_id=str(user.id),
|
||||
email=settings.admin_default_email,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated, Any
|
||||
from uuid import UUID
|
||||
|
|
@ -21,6 +23,7 @@ from ..core.mq.publisher import Publisher
|
|||
from ..core.notifications.publisher import NotificationPublisher
|
||||
from ..core.rate_limit import RateLimiter
|
||||
from ..core.s3.port import Storage
|
||||
from ..core.security.passwords import hash_password
|
||||
from ..core.tokens import hash_token
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
|
@ -117,27 +120,72 @@ async def require_service_token(
|
|||
AuthDep = Annotated[None, Depends(require_service_token)]
|
||||
|
||||
|
||||
async def get_or_create_user_for_telegram(session: AsyncSession, telegram_id: int) -> User:
|
||||
"""Fetch or create a user identified by telegram_id."""
|
||||
async def get_or_create_user_for_telegram(
|
||||
session: AsyncSession,
|
||||
telegram_id: int,
|
||||
*,
|
||||
profile: dict[str, object] | None = None,
|
||||
verified: bool = True,
|
||||
) -> User:
|
||||
"""Fetch or create a user identified by telegram_id.
|
||||
|
||||
On creation stores the profile snapshot, verification flag, and binding time.
|
||||
On existing row optionally refreshes the profile snapshot.
|
||||
"""
|
||||
result = await session.execute(
|
||||
text("SELECT id, telegram_id, created_at, credits_left FROM users WHERE telegram_id = :t"),
|
||||
text(
|
||||
"SELECT id, telegram_id, email, password_hash, is_active, "
|
||||
"created_at, credits_left, telegram_verified "
|
||||
"FROM users WHERE telegram_id = :t"
|
||||
),
|
||||
{"t": telegram_id},
|
||||
)
|
||||
row = result.first()
|
||||
if row:
|
||||
return User(id=row[0], telegram_id=row[1], created_at=row[2], credits_left=row[3])
|
||||
# Refresh profile snapshot on every /start so admin sees current data.
|
||||
if profile:
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE users SET telegram_profile_json = :p, telegram_verified = :v "
|
||||
"WHERE id = :u"
|
||||
),
|
||||
{"p": json.dumps(profile, ensure_ascii=False), "v": verified, "u": row[0]},
|
||||
)
|
||||
await session.commit()
|
||||
return User(
|
||||
id=row[0],
|
||||
telegram_id=row[1],
|
||||
email=row[2],
|
||||
password_hash=row[3],
|
||||
is_active=row[4],
|
||||
created_at=row[5],
|
||||
credits_left=row[6],
|
||||
telegram_verified=verified if profile else row[7],
|
||||
)
|
||||
|
||||
bound_at = dt.datetime.now(tz=dt.UTC)
|
||||
profile_json = json.dumps(profile, ensure_ascii=False) if profile else None
|
||||
insert = await session.execute(
|
||||
text(
|
||||
"INSERT INTO users (telegram_id, credits_left) VALUES (:t, 0) "
|
||||
"RETURNING id, telegram_id, created_at, credits_left"
|
||||
"INSERT INTO users (telegram_id, credits_left, telegram_profile_json, "
|
||||
"telegram_verified, telegram_bound_at) "
|
||||
"VALUES (:t, 0, :p, :v, :b) "
|
||||
"RETURNING id, telegram_id, created_at, credits_left, is_active, "
|
||||
"telegram_verified"
|
||||
),
|
||||
{"t": telegram_id},
|
||||
{"t": telegram_id, "p": profile_json, "v": verified, "b": bound_at},
|
||||
)
|
||||
new = insert.first()
|
||||
assert new is not None
|
||||
await session.commit()
|
||||
return User(id=new[0], telegram_id=new[1], created_at=new[2], credits_left=new[3])
|
||||
return User(
|
||||
id=new[0],
|
||||
telegram_id=new[1],
|
||||
created_at=new[2],
|
||||
credits_left=new[3],
|
||||
is_active=new[4],
|
||||
telegram_verified=new[5],
|
||||
)
|
||||
|
||||
|
||||
async def get_or_create_user_by_id(session: AsyncSession, user_id: UUID) -> User | None:
|
||||
|
|
@ -152,6 +200,39 @@ async def get_or_create_user_by_id(session: AsyncSession, user_id: UUID) -> User
|
|||
return User(id=row[0], telegram_id=row[1], created_at=row[2], credits_left=row[3])
|
||||
|
||||
|
||||
async def bind_telegram_to_user(
|
||||
session: AsyncSession,
|
||||
user_id: UUID,
|
||||
telegram_id: int,
|
||||
) -> None:
|
||||
"""Link a Telegram id to an existing user (web -> Telegram).
|
||||
|
||||
Raises HTTPException 409 if the telegram_id is already bound to another user.
|
||||
"""
|
||||
dup = await session.execute(
|
||||
text("SELECT id FROM users WHERE telegram_id = :t AND id != :u"),
|
||||
{"t": telegram_id, "u": user_id},
|
||||
)
|
||||
if dup.first() is not None:
|
||||
raise HTTPException(status_code=409, detail="Telegram id already bound to another account")
|
||||
|
||||
await session.execute(
|
||||
text("UPDATE users SET telegram_id = :t, telegram_bound_at = now() WHERE id = :u"),
|
||||
{"t": telegram_id, "u": user_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def set_user_password(session: AsyncSession, user_id: UUID, password: str) -> None:
|
||||
"""Set/rotate a web password for a user (Telegram -> web UI access)."""
|
||||
hashed = hash_password(password)
|
||||
await session.execute(
|
||||
text("UPDATE users SET password_hash = :p WHERE id = :u"),
|
||||
{"p": hashed, "u": user_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def fetch_user_by_email(session: AsyncSession, email: str) -> User | None:
|
||||
"""Fetch a user by email (case-sensitive — normalize upstream). Returns None if not found."""
|
||||
result = await session.execute(
|
||||
|
|
|
|||
|
|
@ -61,8 +61,16 @@ log = get_logger(__name__)
|
|||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
class TelegramProfile(BaseModel):
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
language_code: str | None = None
|
||||
|
||||
|
||||
class TelegramBotAuthRequest(BaseModel):
|
||||
telegram_id: int = Field(..., gt=0, description="Verified Telegram user id from aiogram")
|
||||
profile: TelegramProfile | None = None
|
||||
|
||||
|
||||
class TelegramWebAuthRequest(BaseModel):
|
||||
|
|
@ -132,12 +140,31 @@ async def auth_telegram_bot(
|
|||
session: AsyncSessionDep,
|
||||
body: TelegramBotAuthRequest,
|
||||
) -> AuthResponse:
|
||||
"""Exchange a verified telegram_id (from the bot) for a user JWT."""
|
||||
"""Exchange a verified telegram_id (from the bot) for a user JWT.
|
||||
|
||||
Stores/updates the Telegram profile snapshot and sets telegram_verified
|
||||
based on whether the profile looks legitimate.
|
||||
"""
|
||||
identity = verify_bot_identity(body.telegram_id)
|
||||
user = await get_or_create_user_for_telegram(session, identity.telegram_id)
|
||||
profile = body.profile
|
||||
verified = _telegram_profile_looks_verified(profile)
|
||||
user = await get_or_create_user_for_telegram(
|
||||
session,
|
||||
identity.telegram_id,
|
||||
profile=profile.model_dump(exclude_none=True) if profile else None,
|
||||
verified=verified,
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled")
|
||||
return _issue_token(user)
|
||||
|
||||
|
||||
def _telegram_profile_looks_verified(profile: TelegramProfile | None) -> bool:
|
||||
if profile is None:
|
||||
return False
|
||||
return bool(profile.username or profile.first_name or profile.last_name)
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/telegram/web", status_code=status.HTTP_200_OK)
|
||||
async def auth_telegram_web(
|
||||
session: AsyncSessionDep,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,107 @@
|
|||
"""User profile endpoint (credits balance)."""
|
||||
"""User profile endpoint (credits balance + Telegram binding)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..deps import AsyncSessionDep, CurrentUserDep, get_credits
|
||||
from ..deps import (
|
||||
AsyncSessionDep,
|
||||
CurrentUserDep,
|
||||
bind_telegram_to_user,
|
||||
get_credits,
|
||||
set_user_password,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["me"])
|
||||
|
||||
|
||||
@router.get("/api/v1/me")
|
||||
class MeResponse(BaseModel):
|
||||
telegram_id: int | None = None
|
||||
credits_left: int
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class BindTelegramRequest(BaseModel):
|
||||
telegram_id: int = Field(..., gt=0, description="Verified Telegram user id")
|
||||
|
||||
|
||||
class BindTelegramResponse(BaseModel):
|
||||
ok: bool = True
|
||||
telegram_id: int
|
||||
|
||||
|
||||
class SetPasswordRequest(BaseModel):
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
@router.get("/api/v1/me", response_model=MeResponse)
|
||||
async def me(
|
||||
session: AsyncSessionDep,
|
||||
user: CurrentUserDep,
|
||||
) -> dict[str, object]:
|
||||
) -> MeResponse:
|
||||
credits = await get_credits(session, user.user_id)
|
||||
return {"telegram_id": user.telegram_id, "credits_left": credits}
|
||||
return MeResponse(
|
||||
telegram_id=user.telegram_id if user.telegram_id else None,
|
||||
credits_left=credits,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/me/documents")
|
||||
async def list_my_documents(
|
||||
session: AsyncSessionDep,
|
||||
user: CurrentUserDep,
|
||||
limit: int = 10,
|
||||
) -> dict[str, object]:
|
||||
"""Return the current user's recent documents for the bot /reports command."""
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
if limit > 100:
|
||||
limit = 100
|
||||
|
||||
result = await session.execute(
|
||||
text(
|
||||
"SELECT d.id, d.filename, d.status, d.stage, d.created_at "
|
||||
"FROM documents d "
|
||||
"WHERE d.user_id = :u "
|
||||
"ORDER BY d.created_at DESC "
|
||||
"LIMIT :limit"
|
||||
),
|
||||
{"u": user.user_id, "limit": limit},
|
||||
)
|
||||
rows = result.all()
|
||||
return {
|
||||
"documents": [
|
||||
{
|
||||
"document_id": str(row[0]),
|
||||
"filename": row[1],
|
||||
"status": row[2],
|
||||
"stage": row[3],
|
||||
"created_at": row[4].isoformat() if row[4] else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v1/me/telegram", response_model=BindTelegramResponse)
|
||||
async def bind_telegram(
|
||||
session: AsyncSessionDep,
|
||||
user: CurrentUserDep,
|
||||
body: BindTelegramRequest,
|
||||
) -> BindTelegramResponse:
|
||||
"""Link a Telegram account to the current web/email user."""
|
||||
await bind_telegram_to_user(session, user.user_id, body.telegram_id)
|
||||
return BindTelegramResponse(ok=True, telegram_id=body.telegram_id)
|
||||
|
||||
|
||||
@router.post("/api/v1/me/password", status_code=status.HTTP_200_OK)
|
||||
async def set_password(
|
||||
session: AsyncSessionDep,
|
||||
user: CurrentUserDep,
|
||||
body: SetPasswordRequest,
|
||||
) -> dict[str, bool]:
|
||||
"""Let a Telegram-only user set a web password to access the web UI."""
|
||||
await set_user_password(session, user.user_id, body.password)
|
||||
return {"ok": True}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from ..core.logging import bind_context, configure_logging, get_logger
|
|||
from .client import ApiClient
|
||||
from .config import get_bot_settings
|
||||
from .handlers import router as bot_router
|
||||
from .rate_limit import get_rate_limiter
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
|
@ -28,8 +29,12 @@ async def main() -> None:
|
|||
api = ApiClient(settings)
|
||||
await api.start()
|
||||
|
||||
# Rate limiter for /start and other bot-level guards. Redis in prod,
|
||||
# in-memory fallback in dev/tests.
|
||||
rate_limiter = await get_rate_limiter(settings.redis_url or "redis://redis:6379/0")
|
||||
|
||||
bot = Bot(token=settings.bot_token)
|
||||
dp = Dispatcher(api=api, settings=settings)
|
||||
dp = Dispatcher(api=api, settings=settings, rate_limiter=rate_limiter)
|
||||
dp.include_router(bot_router)
|
||||
|
||||
me = await bot.get_me()
|
||||
|
|
@ -56,6 +61,10 @@ async def main() -> None:
|
|||
await dp.stop_polling()
|
||||
await bot.session.close()
|
||||
await api.aclose()
|
||||
try:
|
||||
await rate_limiter.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
log.info("bot_stopped")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -123,11 +123,30 @@ class ApiClient:
|
|||
"X-Correlation-ID": correlation_id,
|
||||
}
|
||||
|
||||
async def login(self, telegram_id: int, correlation_id: str) -> None:
|
||||
"""Exchange a verified telegram_id for a user JWT and cache it."""
|
||||
async def login(
|
||||
self,
|
||||
telegram_id: int,
|
||||
correlation_id: str,
|
||||
*,
|
||||
username: str | None = None,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
language_code: str | None = None,
|
||||
) -> None:
|
||||
"""Exchange a verified telegram_id (+ profile snapshot) for a user JWT."""
|
||||
payload: dict[str, object] = {"telegram_id": telegram_id}
|
||||
profile: dict[str, str | None] = {
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"language_code": language_code,
|
||||
}
|
||||
if any(v is not None for v in profile.values()):
|
||||
payload["profile"] = profile
|
||||
|
||||
r = await self.client.post(
|
||||
"/api/v1/auth/telegram/bot",
|
||||
json={"telegram_id": telegram_id},
|
||||
json=payload,
|
||||
headers=self._service_headers(correlation_id),
|
||||
)
|
||||
if r.status_code != 200:
|
||||
|
|
@ -197,3 +216,31 @@ class ApiClient:
|
|||
markdown=body.get("markdown"),
|
||||
filename=body.get("filename"),
|
||||
)
|
||||
|
||||
async def get_me(self, telegram_id: int, correlation_id: str) -> dict[str, object]:
|
||||
"""Fetch current user profile from GET /api/v1/me."""
|
||||
r = await self.client.get(
|
||||
"/api/v1/me",
|
||||
headers=self._user_headers(telegram_id, correlation_id),
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise ApiError(r.status_code, _extract_detail(r))
|
||||
body = r.json()
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(500, "unexpected me response")
|
||||
return body
|
||||
|
||||
async def list_documents(
|
||||
self, telegram_id: int, correlation_id: str, *, limit: int = 10
|
||||
) -> list[dict[str, object]]:
|
||||
"""List the user's recent documents from GET /api/v1/me/documents."""
|
||||
r = await self.client.get(
|
||||
"/api/v1/me/documents",
|
||||
params={"limit": limit},
|
||||
headers=self._user_headers(telegram_id, correlation_id),
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise ApiError(r.status_code, _extract_detail(r))
|
||||
body = r.json()
|
||||
docs = body.get("documents") if isinstance(body, dict) else body
|
||||
return docs if isinstance(docs, list) else []
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ class BotSettings(BaseSettings):
|
|||
default="http://api:8000",
|
||||
description="Base URL of the api service (HTTP-only target).",
|
||||
)
|
||||
redis_url: str = Field(
|
||||
default="redis://redis:6379/0",
|
||||
description="Redis URL for the bot rate limiter (optional; falls back to memory).",
|
||||
)
|
||||
bot_service_token: str = Field(
|
||||
...,
|
||||
description="Bearer token authenticating the bot against the api (service_tokens).",
|
||||
|
|
@ -43,6 +47,16 @@ class BotSettings(BaseSettings):
|
|||
poll_timeout: float = 300.0
|
||||
long_message_threshold: int = 4096
|
||||
|
||||
# /start hardening
|
||||
bot_start_rate_limit_rps: float = Field(
|
||||
default=5.0,
|
||||
description="Max /start requests per second per telegram_id (token-bucket rate limit).",
|
||||
)
|
||||
bot_require_profile: bool = Field(
|
||||
default=True,
|
||||
description="If true, empty Telegram profiles (no username/name) are allowed but flagged for review.",
|
||||
)
|
||||
|
||||
@property
|
||||
def json_logs(self) -> bool:
|
||||
return self.env != "dev"
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ import io
|
|||
|
||||
from aiogram import Bot, F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import BufferedInputFile, Document, Message
|
||||
from aiogram.types import BufferedInputFile, Document, Message, User
|
||||
|
||||
from ..core.logging import get_logger, new_correlation_id
|
||||
from ..core.rate_limit import RateLimiter
|
||||
from .client import (
|
||||
ApiClient,
|
||||
ApiError,
|
||||
|
|
@ -58,27 +59,188 @@ def _user_id(message: Message) -> int:
|
|||
return message.from_user.id if message.from_user else 0
|
||||
|
||||
|
||||
@router.message(Command("start", "help"))
|
||||
async def cmd_start(message: Message, api: ApiClient) -> None:
|
||||
cid = new_correlation_id()
|
||||
def _profile(user: User | None) -> dict[str, str | None]:
|
||||
if user is None:
|
||||
return {"username": None, "first_name": None, "last_name": None, "language_code": None}
|
||||
return {
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"language_code": user.language_code,
|
||||
}
|
||||
|
||||
|
||||
def _looks_like_profile(user: User | None) -> bool:
|
||||
if user is None:
|
||||
return False
|
||||
return bool(user.username or user.first_name or user.last_name)
|
||||
|
||||
|
||||
async def _ensure_login(
|
||||
message: Message,
|
||||
api: ApiClient,
|
||||
correlation_id: str,
|
||||
) -> None:
|
||||
tg = _user_id(message)
|
||||
profile = _profile(message.from_user)
|
||||
await api.login(
|
||||
tg,
|
||||
correlation_id,
|
||||
username=profile.get("username"),
|
||||
first_name=profile.get("first_name"),
|
||||
last_name=profile.get("last_name"),
|
||||
language_code=profile.get("language_code"),
|
||||
)
|
||||
|
||||
|
||||
async def _check_rate_limit(
|
||||
message: Message,
|
||||
rate_limiter: RateLimiter,
|
||||
settings: BotSettings,
|
||||
) -> bool:
|
||||
"""Return True if the call is allowed. If blocked, reply to the user."""
|
||||
tg = _user_id(message)
|
||||
limit = settings.bot_start_rate_limit_rps
|
||||
result = await rate_limiter.allow(f"bot:start:{tg}", int(limit))
|
||||
if not result.allowed:
|
||||
log.warning(
|
||||
"start_rate_limited",
|
||||
telegram_id=tg,
|
||||
retry_after=result.retry_after_sec,
|
||||
)
|
||||
await message.answer("Слишком много запросов. Подождите немного и попробуйте снова.")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@router.message(Command("start", "help"))
|
||||
async def cmd_start(
|
||||
message: Message,
|
||||
api: ApiClient,
|
||||
settings: BotSettings,
|
||||
rate_limiter: RateLimiter,
|
||||
) -> None:
|
||||
cid = new_correlation_id()
|
||||
if not await _check_rate_limit(message, rate_limiter, settings):
|
||||
return
|
||||
try:
|
||||
await api.login(tg, cid)
|
||||
credits = await api.get_credits(tg, cid)
|
||||
await _ensure_login(message, api, cid)
|
||||
credits = await api.get_credits(_user_id(message), cid)
|
||||
except ApiError as exc:
|
||||
log.error("me_failed", correlation_id=cid, error=str(exc))
|
||||
log.error("start_failed", correlation_id=cid, error=str(exc))
|
||||
await message.answer(
|
||||
"Привет! Я «Контракт-чек» — скрининг рисков в договорах.\n"
|
||||
"Не удалось связаться с сервисом, попробуйте позже."
|
||||
)
|
||||
return
|
||||
await message.answer(
|
||||
"Привет! Я «Контракт-чек» — первичный скрининг рисков в договорах "
|
||||
"по ГК РФ / ГК РБ.\n\n"
|
||||
"Пришлите PDF или DOCX договор — я проверю его по чек-листу и пришлю "
|
||||
"отчёт с рисками и рекомендациями.\n\n"
|
||||
f"Осталось проверок: {credits}."
|
||||
)
|
||||
|
||||
if settings.bot_require_profile and not _looks_like_profile(message.from_user):
|
||||
greeting = (
|
||||
"Привет! Я «Контракт-чек» — первичный скрининг рисков в договорах "
|
||||
"по ГК РФ / ГК РБ.\n\n"
|
||||
"Ваш Telegram-профиль пустой, поэтому аккаунт отправлен на ручную проверку. "
|
||||
"Вы уже можете присылать договоры, но загрузки могут быть ограничены до проверки.\n\n"
|
||||
f"Осталось проверок: {credits}."
|
||||
"\n\nДоступные команды:\n"
|
||||
"/start — приветствие и баланс\n"
|
||||
"/balance — остаток проверок\n"
|
||||
"/reports — ваши последние документы\n"
|
||||
"/status <id> — статус одного документа"
|
||||
)
|
||||
else:
|
||||
greeting = (
|
||||
"Привет! Я «Контракт-чек» — первичный скрининг рисков в договорах "
|
||||
"по ГК РФ / ГК РБ.\n\n"
|
||||
"Пришлите PDF или DOCX договор — я проверю его по чек-листу и пришлю "
|
||||
"отчёт с рисками и рекомендациями.\n\n"
|
||||
f"Осталось проверок: {credits}."
|
||||
"\n\nДоступные команды:\n"
|
||||
"/start — приветствие и баланс\n"
|
||||
"/balance — остаток проверок\n"
|
||||
"/reports — ваши последние документы\n"
|
||||
"/status <id> — статус одного документа"
|
||||
)
|
||||
await message.answer(greeting)
|
||||
|
||||
|
||||
@router.message(Command("balance"))
|
||||
async def cmd_balance(message: Message, api: ApiClient) -> None:
|
||||
cid = new_correlation_id()
|
||||
tg = _user_id(message)
|
||||
try:
|
||||
await _ensure_login(message, api, cid)
|
||||
credits = await api.get_credits(tg, cid)
|
||||
except ApiError as exc:
|
||||
log.error("balance_failed", correlation_id=cid, error=str(exc))
|
||||
await message.answer("Не удалось получить баланс. Попробуйте позже.")
|
||||
return
|
||||
await message.answer(f"Осталось проверок: {credits}.")
|
||||
|
||||
|
||||
@router.message(Command("reports"))
|
||||
async def cmd_reports(message: Message, api: ApiClient) -> None:
|
||||
cid = new_correlation_id()
|
||||
tg = _user_id(message)
|
||||
try:
|
||||
await _ensure_login(message, api, cid)
|
||||
docs = await api.list_documents(tg, cid, limit=10)
|
||||
except ApiError as exc:
|
||||
log.error("reports_failed", correlation_id=cid, error=str(exc))
|
||||
await message.answer("Не удалось загрузить список документов. Попробуйте позже.")
|
||||
return
|
||||
|
||||
if not docs:
|
||||
await message.answer("У вас пока нет проверенных документов. Пришлите PDF или DOCX.")
|
||||
return
|
||||
|
||||
lines = ["Ваши последние документы:"]
|
||||
for raw in docs:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
doc: dict[str, object] = raw
|
||||
doc_id = str(doc.get("document_id", "?"))
|
||||
filename = doc.get("filename") or "без имени"
|
||||
status = str(doc.get("status") or "unknown")
|
||||
stage = doc.get("stage")
|
||||
label = _STAGE_LABELS.get(str(stage or status), "Обрабатываю…")
|
||||
lines.append(f"\n📄 {filename}\nID: {doc_id}\nСтатус: {label}")
|
||||
lines.append("\nДля подробностей: /status <id>")
|
||||
await message.answer("\n".join(lines))
|
||||
|
||||
|
||||
@router.message(Command("status"))
|
||||
async def cmd_status(message: Message, api: ApiClient) -> None:
|
||||
cid = new_correlation_id()
|
||||
tg = _user_id(message)
|
||||
args = message.text.split()[1:] if message.text else []
|
||||
if not args:
|
||||
await message.answer("Укажите ID документа: /status <id>")
|
||||
return
|
||||
document_id = args[0]
|
||||
try:
|
||||
await _ensure_login(message, api, cid)
|
||||
report = await api.get_report(tg, cid, document_id)
|
||||
except ApiError as exc:
|
||||
log.warning("status_failed", correlation_id=cid, document_id=document_id, error=str(exc))
|
||||
await message.answer(
|
||||
"Не удалось получить статус документа. Проверьте ID и попробуйте снова."
|
||||
)
|
||||
return
|
||||
|
||||
label = _stage_label(report)
|
||||
if report.status == "done" and report.markdown:
|
||||
bot = message.bot
|
||||
if bot is None:
|
||||
await message.answer("Внутренняя ошибка: бот недоступен.")
|
||||
return
|
||||
await _deliver_report(bot, message.chat.id, report, report.filename or "документ", 4096)
|
||||
return
|
||||
if report.status == "failed":
|
||||
await message.answer(
|
||||
"Не удалось обработать документ. Проверка списана не будет — попробуйте другой файл."
|
||||
)
|
||||
return
|
||||
await message.answer(f"Статус: {label}")
|
||||
|
||||
|
||||
@router.message(F.document)
|
||||
|
|
@ -110,7 +272,7 @@ async def handle_document(message: Message, api: ApiClient, settings: BotSetting
|
|||
|
||||
content_type = document.mime_type or _CONTENT_TYPES[suffix]
|
||||
try:
|
||||
await api.login(tg, cid)
|
||||
await _ensure_login(message, api, cid)
|
||||
upload = await api.upload_document(tg, cid, document.file_name, data, content_type)
|
||||
except NoCreditsError:
|
||||
await _edit(status_msg, "У вас закончились проверки. Пополните баланс, чтобы продолжить.")
|
||||
|
|
|
|||
38
src/contract_check/bot/rate_limit.py
Normal file
38
src/contract_check/bot/rate_limit.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""Bot-specific rate limiter factory.
|
||||
|
||||
Keeps the bot entrypoint free of infra imports that would break the boundary
|
||||
in tests/unit/test_bot_boundary.py. Redis is resolved lazily at runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from ..core.rate_limit import MemoryRateLimiter, RateLimiter
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def _redis_client(url: str) -> Any:
|
||||
from ..core.redis_client import get_redis_client
|
||||
|
||||
return get_redis_client(url)
|
||||
|
||||
|
||||
async def get_rate_limiter(redis_url: str) -> RateLimiter:
|
||||
"""Build a Redis rate limiter or fall back to memory on failure."""
|
||||
redis_client = _redis_client(redis_url)
|
||||
try:
|
||||
await redis_client.ping()
|
||||
from ..core.rate_limit import RedisRateLimiter
|
||||
|
||||
log.info("rate_limiter_redis_ready")
|
||||
return RedisRateLimiter(redis_client)
|
||||
except Exception as exc:
|
||||
log.warning("rate_limiter_redis_unavailable", error=str(exc))
|
||||
try:
|
||||
await redis_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
return MemoryRateLimiter()
|
||||
|
|
@ -104,6 +104,24 @@ class Settings(BaseSettings):
|
|||
# Minimum password length enforced at register / reset.
|
||||
password_min_length: int = 8
|
||||
|
||||
# --- admin panel (server-rendered, mounted at /admin in the api) ---
|
||||
web_admin_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Toggle for the /admin management UI (users, future subscriptions)",
|
||||
)
|
||||
admin_required_role: str = Field(
|
||||
default="admin",
|
||||
description="users.role value required to enter /admin (must match the DB CHECK constraint)",
|
||||
)
|
||||
admin_default_email: str = Field(
|
||||
default="admin@contract-check.local",
|
||||
description="Default admin panel login email (auto-created if no admin exists)",
|
||||
)
|
||||
admin_default_password: str = Field(
|
||||
default="",
|
||||
description="Default admin panel password. Empty disables auto-admin creation.",
|
||||
)
|
||||
|
||||
# --- SMTP (notification transport; empty host disables sending) ---
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
|
|
|
|||
|
|
@ -65,3 +65,9 @@ INVOICE_STATUSES: tuple[str, ...] = (
|
|||
# refund policy switch (REFUND_POLICY env)
|
||||
RefundPolicyLike = Literal["all", "infra_only"]
|
||||
REFUND_POLICIES: tuple[str, ...] = ("all", "infra_only")
|
||||
|
||||
# users.role — admin-panel RBAC (docs/ARCHITECTURE.md §admin)
|
||||
UserRole = Literal["user", "admin"]
|
||||
USER_ROLES: tuple[str, ...] = ("user", "admin")
|
||||
USER_ROLE_USER: UserRole = "user"
|
||||
USER_ROLE_ADMIN: UserRole = "admin"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ class User(Base):
|
|||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default=text("true")
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String, nullable=False, default="user", server_default=text("'user'")
|
||||
)
|
||||
telegram_profile_json: Mapped[str | None] = mapped_column(Text)
|
||||
telegram_verified: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default=text("true")
|
||||
)
|
||||
telegram_bound_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
|
@ -64,6 +72,7 @@ class User(Base):
|
|||
"telegram_id IS NOT NULL OR email IS NOT NULL",
|
||||
name="users_identity_present",
|
||||
),
|
||||
CheckConstraint("role IN ('user', 'admin')", name="users_role_check"),
|
||||
)
|
||||
|
||||
documents: Mapped[list[Document]] = relationship(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ class RateLimiter(Protocol):
|
|||
|
||||
async def allow(self, key: str, limit_rps: int) -> RateLimitResult: ...
|
||||
|
||||
async def aclose(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimitResult:
|
||||
|
|
@ -102,6 +104,10 @@ class RedisRateLimiter:
|
|||
retry_after = None if allowed else float(raw[1])
|
||||
return RateLimitResult(allowed=allowed, retry_after_sec=retry_after)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the underlying Redis client."""
|
||||
await self._redis.aclose()
|
||||
|
||||
|
||||
class MemoryRateLimiter:
|
||||
"""In-memory token bucket for unit tests and dev without Redis."""
|
||||
|
|
@ -129,3 +135,6 @@ class MemoryRateLimiter:
|
|||
retry_after = (1.0 - tokens) / refill_rate
|
||||
self._buckets[key] = (tokens, now)
|
||||
return RateLimitResult(allowed=False, retry_after_sec=retry_after)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""No-op for the in-memory backend."""
|
||||
|
|
|
|||
189
tests/integration/test_admin_panel.py
Normal file
189
tests/integration/test_admin_panel.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"""Admin panel (/admin) integration tests: auth gate, create, edit, ban.
|
||||
|
||||
Run against the Docker Compose infrastructure (`docker compose up -d`).
|
||||
Covers the server-rendered FastAPI + Jinja2 + HTMX panel mounted in the api.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from contract_check.core.security.passwords import hash_password
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def _seed_user(
|
||||
db_session,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
role: str = "user",
|
||||
is_active: bool = True,
|
||||
credits_left: int = 0,
|
||||
) -> str:
|
||||
"""Insert a user and return its id (cleaned up by the per-test session rollback
|
||||
semantics; we also delete explicitly to keep tables tidy across tests)."""
|
||||
result = await db_session.execute(
|
||||
text(
|
||||
"INSERT INTO users (email, password_hash, role, is_active, credits_left) "
|
||||
"VALUES (:e, :p, :r, :a, :c) RETURNING id"
|
||||
),
|
||||
{
|
||||
"e": email,
|
||||
"p": hash_password(password),
|
||||
"r": role,
|
||||
"a": is_active,
|
||||
"c": credits_left,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
user_id = result.first()[0]
|
||||
return str(user_id)
|
||||
|
||||
|
||||
async def _login_as(client: httpx.AsyncClient, email: str, password: str) -> httpx.Response:
|
||||
return await client.post(
|
||||
"/admin/login", data={"email": email, "password": password}, follow_redirects=False
|
||||
)
|
||||
|
||||
|
||||
async def test_admin_login_sets_cookie_for_admin(client: httpx.AsyncClient, db_session) -> None:
|
||||
email = f"admin-{uuid.uuid4().hex[:8]}@test.local"
|
||||
await _seed_user(db_session, email=email, password="adminpass-123", role="admin")
|
||||
|
||||
r = await _login_as(client, email, "adminpass-123")
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/admin/users"
|
||||
assert "cc_admin_token" in r.cookies
|
||||
|
||||
|
||||
async def test_admin_login_rejects_non_admin_role(client: httpx.AsyncClient, db_session) -> None:
|
||||
email = f"user-{uuid.uuid4().hex[:8]}@test.local"
|
||||
await _seed_user(db_session, email=email, password="userpass-123", role="user")
|
||||
|
||||
r = await _login_as(client, email, "userpass-123")
|
||||
assert r.status_code == 303
|
||||
assert "forbidden" in r.headers["location"]
|
||||
assert "cc_admin_token" not in r.cookies
|
||||
|
||||
|
||||
async def test_admin_routes_redirect_without_session(client: httpx.AsyncClient) -> None:
|
||||
r = await client.get("/admin/users", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"].startswith("/admin/login")
|
||||
|
||||
|
||||
async def test_admin_creates_user_and_redirects_to_detail(
|
||||
client: httpx.AsyncClient, db_session
|
||||
) -> None:
|
||||
admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local"
|
||||
await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin")
|
||||
await _login_as(client, admin_email, "adminpass-123")
|
||||
|
||||
new_email = f"new-{uuid.uuid4().hex[:8]}@test.local"
|
||||
r = await client.post(
|
||||
"/admin/users",
|
||||
data={
|
||||
"email": new_email,
|
||||
"password": "newpass-1234",
|
||||
"role": "admin",
|
||||
"credits_left": "7",
|
||||
"is_active": "on",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
location = r.headers["location"]
|
||||
assert location.startswith("/admin/users/")
|
||||
|
||||
# Persisted with the chosen role + credits.
|
||||
row = (
|
||||
await db_session.execute(
|
||||
text(
|
||||
"SELECT credits_left, role, is_active, password_hash IS NOT NULL "
|
||||
"FROM users WHERE email = :e"
|
||||
),
|
||||
{"e": new_email},
|
||||
)
|
||||
).first()
|
||||
assert row is not None
|
||||
assert row[0] == 7
|
||||
assert row[1] == "admin"
|
||||
assert row[2] is True
|
||||
assert row[3] is True
|
||||
|
||||
# Detail page renders the created user.
|
||||
detail = await client.get(location, follow_redirects=False)
|
||||
assert detail.status_code == 200
|
||||
assert new_email in detail.text
|
||||
|
||||
|
||||
async def test_admin_create_rejects_duplicate_email(client: httpx.AsyncClient, db_session) -> None:
|
||||
admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local"
|
||||
existing = f"dup-{uuid.uuid4().hex[:8]}@test.local"
|
||||
await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin")
|
||||
await _seed_user(db_session, email=existing, password="somepass-123")
|
||||
await _login_as(client, admin_email, "adminpass-123")
|
||||
|
||||
r = await client.post(
|
||||
"/admin/users",
|
||||
data={"email": existing, "password": "anotherpass-123"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 200 # form re-rendered, not a redirect/5xx
|
||||
assert "уже существует" in r.text
|
||||
|
||||
|
||||
async def test_admin_create_rejects_short_password(client: httpx.AsyncClient, db_session) -> None:
|
||||
admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local"
|
||||
await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin")
|
||||
await _login_as(client, admin_email, "adminpass-123")
|
||||
|
||||
r = await client.post(
|
||||
"/admin/users",
|
||||
data={"email": f"short-{uuid.uuid4().hex[:8]}@test.local", "password": "x"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "Пароль короче" in r.text
|
||||
|
||||
|
||||
async def test_admin_new_form_resolves_before_dynamic_route(
|
||||
client: httpx.AsyncClient, db_session
|
||||
) -> None:
|
||||
"""`/admin/users/new` must hit the form route, not be parsed as {user_id}."""
|
||||
admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local"
|
||||
await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin")
|
||||
await _login_as(client, admin_email, "adminpass-123")
|
||||
|
||||
r = await client.get("/admin/users/new", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert "Новый пользователь" in r.text
|
||||
|
||||
|
||||
async def test_admin_toggle_active_bans_user(client: httpx.AsyncClient, db_session) -> None:
|
||||
admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local"
|
||||
await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin")
|
||||
await _login_as(client, admin_email, "adminpass-123")
|
||||
|
||||
target_email = f"ban-{uuid.uuid4().hex[:8]}@test.local"
|
||||
target_id = await _seed_user(db_session, email=target_email, password="targetpass-12")
|
||||
|
||||
r = await client.post(
|
||||
f"/admin/users/{target_id}/toggle-active",
|
||||
headers={"hx-request": "true"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
is_active = (
|
||||
await db_session.execute(
|
||||
text("SELECT is_active FROM users WHERE id = :u"), {"u": target_id}
|
||||
)
|
||||
).scalar_one()
|
||||
assert is_active is False
|
||||
|
|
@ -150,7 +150,7 @@ async def test_b2b_get_report_before_ready_returns_status(
|
|||
assert poll.status_code == 200
|
||||
body = poll.json()
|
||||
assert body["document_id"] == document_id
|
||||
assert body["status"] == "queued"
|
||||
assert body["status"] in ("queued", "extracting")
|
||||
assert "stage" in body
|
||||
|
||||
|
||||
|
|
|
|||
22
uv.lock
generated
22
uv.lock
generated
|
|
@ -584,6 +584,7 @@ api = [
|
|||
{ name = "asyncpg" },
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "minio" },
|
||||
{ name = "opentelemetry-exporter-otlp" },
|
||||
{ name = "opentelemetry-instrumentation-asgi" },
|
||||
|
|
@ -600,6 +601,7 @@ api = [
|
|||
]
|
||||
bot = [
|
||||
{ name = "aiogram" },
|
||||
{ name = "redis" },
|
||||
]
|
||||
db = [
|
||||
{ name = "alembic" },
|
||||
|
|
@ -618,6 +620,7 @@ dev = [
|
|||
{ name = "asyncpg" },
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "minio" },
|
||||
{ name = "mypy" },
|
||||
{ name = "opentelemetry-exporter-otlp" },
|
||||
|
|
@ -714,6 +717,7 @@ api = [
|
|||
{ name = "asyncpg", specifier = ">=0.29" },
|
||||
{ name = "email-validator", specifier = ">=2.1" },
|
||||
{ name = "fastapi", specifier = ">=0.110" },
|
||||
{ name = "jinja2", specifier = ">=3.1" },
|
||||
{ name = "minio", specifier = ">=7.2" },
|
||||
{ name = "opentelemetry-exporter-otlp", specifier = ">=1.24" },
|
||||
{ name = "opentelemetry-instrumentation-asgi", specifier = ">=0.45b0" },
|
||||
|
|
@ -728,7 +732,10 @@ api = [
|
|||
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29" },
|
||||
]
|
||||
bot = [{ name = "aiogram", specifier = ">=3.4" }]
|
||||
bot = [
|
||||
{ name = "aiogram", specifier = ">=3.4" },
|
||||
{ name = "redis", specifier = ">=5.0" },
|
||||
]
|
||||
db = [
|
||||
{ name = "alembic", specifier = ">=1.13" },
|
||||
{ name = "asyncpg", specifier = ">=0.29" },
|
||||
|
|
@ -746,6 +753,7 @@ dev = [
|
|||
{ name = "asyncpg", specifier = ">=0.29" },
|
||||
{ name = "email-validator", specifier = ">=2.1" },
|
||||
{ name = "fastapi", specifier = ">=0.110" },
|
||||
{ name = "jinja2", specifier = ">=3.1" },
|
||||
{ name = "minio", specifier = ">=7.2" },
|
||||
{ name = "mypy", specifier = ">=1.10" },
|
||||
{ name = "opentelemetry-exporter-otlp", specifier = ">=1.24" },
|
||||
|
|
@ -1221,6 +1229,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.15.0"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue