Admin panel was added. TG bot commands were extended.

This commit is contained in:
febux 2026-08-14 00:30:39 +03:00
parent 9c9181bc50
commit 3828b46985
36 changed files with 2054 additions and 55 deletions

View file

@ -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

View file

@ -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
# ─────────────────────────────────────────────────────────────────────────────

View file

@ -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` (актуализируется).

View file

@ -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"

View file

@ -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 15 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 15 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 15 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.

View file

@ -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

View 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")

View 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")

View file

@ -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" },

View 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``.
"""

View 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)

View 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

View 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>

View 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>

View 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 %}

View 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 %}

View 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&amp;unverified={{ '1' if unverified else '0' }}">Только TG</a>
<a class="btn sm {{ 'secondary' if not unverified else '' }}" href="/admin/users?unverified=1&amp;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 %}

View 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 %}

View 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

View 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

View file

@ -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,
)

View file

@ -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(

View file

@ -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,

View file

@ -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}

View file

@ -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")

View file

@ -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 []

View file

@ -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"

View file

@ -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, "У вас закончились проверки. Пополните баланс, чтобы продолжить.")

View 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()

View file

@ -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

View file

@ -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"

View file

@ -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(

View file

@ -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."""

View 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

View file

@ -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
View file

@ -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"