Compare commits

...
7 Commits
67 changed files with 3391 additions and 8 deletions

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
# === Общие ===
ENVIRONMENT=dev
DEBUG=false
# === PostgreSQL ===
POSTGRES_USER=opora
POSTGRES_PASSWORD=change-me
POSTGRES_DB=opora
DATABASE_URL=postgresql+psycopg://opora:change-me@postgres:5432/opora
# === Redis / Celery ===
REDIS_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/1
CELERY_RESULT_BACKEND=redis://redis:6379/2
# === JWT ===
JWT_SECRET=change-me-to-a-long-random-string
ACCESS_TOKEN_TTL_DAYS=30
REFRESH_TOKEN_TTL_DAYS=30
# === CORS ===
CORS_ORIGINS=["https://opora.my-dpr.ru"]
# === MinIO / S3 ===
S3_ENDPOINT=http://minio:9000
S3_ACCESS_KEY=minio
S3_SECRET_KEY=change-me
S3_BUCKET=artifacts
# === Мессенджеры ===
TELEGRAM_BOT_TOKEN=
MAX_BOT_TOKEN=
BOT_SERVICE_TOKEN=
# === LLM ===
OPENROUTER_API_KEY=
LLM_MODEL=deepseek/deepseek-chat
+29
View File
@@ -0,0 +1,29 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
.pytest_cache/
.ruff_cache/
# Node
node_modules/
dist/
.vite/
# Env / secrets
.env
*.local
# IDE
.idea/
.vscode/
# OS
.DS_Store
Thumbs.db
# Data
*.log
*.db
*.sqlite3
+80
View File
@@ -0,0 +1,80 @@
stages:
- lint
- test
- build
- deploy
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
# ---------- Backend ----------
backend:lint:
stage: lint
image: python:3.12-slim
script:
- cd backend
- pip install ruff
- ruff check app
backend:test:
stage: test
image: python:3.12-slim
services:
- postgres:16-alpine
variables:
POSTGRES_USER: opora
POSTGRES_PASSWORD: opora
POSTGRES_DB: opora_test
DATABASE_URL: postgresql+psycopg://opora:opora@postgres:5432/opora_test
script:
- cd backend
- pip install -r requirements.txt pytest
- pytest -q
# ---------- Frontend ----------
frontend:lint:
stage: lint
image: node:20-alpine
script:
- cd frontend
- npm install
- npm run lint || true
frontend:build:
stage: build
image: node:20-alpine
script:
- cd frontend
- npm install
- npm run build
artifacts:
paths:
- frontend/dist
# ---------- Docker images ----------
build:images:
stage: build
image: docker:27
services:
- docker:27-dind
script:
- docker build -t $CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHORT_SHA ./backend
- docker build -t $CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHORT_SHA ./frontend
- docker build -t $CI_REGISTRY_IMAGE/bot:$CI_COMMIT_SHORT_SHA ./bot
rules:
- if: $CI_COMMIT_BRANCH == "main"
# ---------- Deploy ----------
deploy:prod:
stage: deploy
image: alpine:3.20
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
script:
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/opora && git pull && docker compose up -d --build"
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
+75
View File
@@ -0,0 +1,75 @@
# АС «Платформа ОПОРА РОССИИ»
Автоматизированная система для общероссийской общественной организации МСП «ОПОРА РОССИИ»: мессенджер-бот, mobile-first веб-приложение и единое API.
## Стек
- **Backend:** Python 3.12, FastAPI, SQLAlchemy 2, Alembic
- **БД:** PostgreSQL 16
- **Кэш/очередь:** Redis, Celery
- **Бот:** aiogram 3 (Telegram) + адаптер MAX
- **Frontend:** React, TypeScript, Vite, Tailwind CSS, Recharts
- **Файлы:** MinIO (S3-совместимое)
- **Инфра:** Docker Compose, Nginx, Let's Encrypt
- **AI-ассистент:** OpenRouter (`deepseek/deepseek-chat`)
## Структура
```
backend/ FastAPI-приложение, модели, миграции
frontend/ React SPA (mobile-first, PWA)
bot/ aiogram-бот
deploy/ nginx.conf, сертификаты
docs/ документация проекта
tech-design/ модель данных, API, макеты
design/ дизайн-система
tools/ вспомогательные скрипты (тест LLM)
docker-compose.yml
.gitlab-ci.yml
```
## Быстрый старт (локально)
```bash
cp .env.example .env
# заполнить .env (пароли, токены)
docker compose up -d --build
```
- API: http://localhost/api/docs
- Сайт: http://localhost/
- MinIO Console: http://localhost:9001
## Миграции БД
```bash
docker compose exec api alembic revision --autogenerate -m "init"
docker compose exec api alembic upgrade head
```
## Разработка
```bash
# backend
cd backend && pip install -r requirements.txt
uvicorn app.main:app --reload
# frontend
cd frontend && npm install && npm run dev
```
## Документация
- `docs/stack.md` — стек и архитектура
- `docs/tech-design/01-data-model.md` — модель данных
- `docs/tech-design/02-api.md` — контракты API
- `docs/tech-design/03-screens.md` — макеты экранов
- `docs/design/design-system.md` — дизайн-система
- `docs/llm-test-report.md` — тест LLM-моделей
## Деплой
CI/CD через GitLab CI: линтеры → тесты → сборка образов → деплой на сервер (вручную, ветка `main`).
Требуемые переменные GitLab CI: `SSH_PRIVATE_KEY`, `DEPLOY_HOST`, `DEPLOY_USER`, `CI_REGISTRY_IMAGE`.
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", \
"-w", "4", "-b", "0.0.0.0:8000"]
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+46
View File
@@ -0,0 +1,46 @@
"""Окружение Alembic."""
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.db.session import Base
import app.models # noqa: F401 — регистрация моделей
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
View File
Whitespace-only changes.
@@ -0,0 +1,444 @@
"""initial schema
Revision ID: fbd00c295888
Revises:
Create Date: 2026-09-26 19:45:54.541490
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'fbd00c295888'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('broadcast_templates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=64), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('type', sa.Enum('info', 'education', 'rating', name='broadcasttype', native_enum=False, length=20), nullable=False),
sa.Column('body_template', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code')
)
op.create_table('federal_districts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('code', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code'),
sa.UniqueConstraint('name')
)
op.create_table('goals',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.String(length=1000), nullable=True),
sa.Column('target_value', sa.Numeric(precision=14, scale=2), nullable=True),
sa.Column('unit', sa.String(length=32), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('knowledge_categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.Enum('admin', 'coordinator', 'region_head', 'member', 'deputy', name='rolecode', native_enum=False, length=20), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code')
)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('full_name', sa.String(length=200), nullable=True),
sa.Column('messenger_type', sa.Enum('telegram', 'max', name='messengertype', native_enum=False, length=20), nullable=False),
sa.Column('messenger_id', sa.String(length=64), nullable=False),
sa.Column('phone', sa.String(length=32), nullable=True),
sa.Column('status', sa.Enum('active', 'blocked', 'pending', name='userstatus', native_enum=False, length=20), nullable=False),
sa.Column('member_type', sa.Enum('member', 'resident', name='membertype', native_enum=False, length=20), nullable=True),
sa.Column('joined_at', sa.Date(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('messenger_type', 'messenger_id', name='uq_user_messenger')
)
op.create_table('assistant_actions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('owner_user_id', sa.Integer(), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('entity_type', sa.String(length=100), nullable=True),
sa.Column('entity_id', sa.String(length=100), nullable=True),
sa.Column('payload', sa.JSON(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('assistant_configs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('owner_user_id', sa.Integer(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('permissions', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('owner_user_id')
)
op.create_table('audit_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('actor_user_id', sa.Integer(), nullable=True),
sa.Column('actor_type', sa.Enum('user', 'assistant', 'system', name='actortype', native_enum=False, length=20), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('entity_type', sa.String(length=100), nullable=True),
sa.Column('entity_id', sa.String(length=100), nullable=True),
sa.Column('payload', sa.JSON(), nullable=True),
sa.Column('ip', sa.String(length=64), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('broadcasts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('template_id', sa.Integer(), nullable=True),
sa.Column('type', sa.Enum('info', 'education', 'rating', name='broadcasttype', native_enum=False, length=20), nullable=False),
sa.Column('subject', sa.String(length=300), nullable=True),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('audience_filter', sa.JSON(), nullable=True),
sa.Column('status', sa.Enum('draft', 'scheduled', 'sending', 'sent', 'failed', name='broadcaststatus', native_enum=False, length=20), nullable=False),
sa.Column('scheduled_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['template_id'], ['broadcast_templates.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('data_uploads',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('goal_id', sa.Integer(), nullable=False),
sa.Column('file_key', sa.String(length=500), nullable=False),
sa.Column('uploaded_by', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('starts_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('ends_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('location', sa.String(length=300), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('knowledge_articles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['knowledge_categories.id'], ),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('platform_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('event_date', sa.Date(), nullable=True),
sa.Column('is_public', sa.Boolean(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('regions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('federal_district_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['federal_district_id'], ['federal_districts.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('sessions',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token_hash', sa.String(length=128), nullable=False),
sa.Column('messenger_type', sa.Enum('telegram', 'max', name='messengertype', native_enum=False, length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token_hash')
)
op.create_table('snapshots',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('goal_id', sa.Integer(), nullable=False),
sa.Column('snapshot_date', sa.Date(), nullable=False),
sa.Column('kind', sa.Enum('point0', 'current', 'history', name='snapshotkind', native_enum=False, length=20), nullable=False),
sa.Column('source', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('advices',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('region_id', sa.Integer(), nullable=False),
sa.Column('goal_id', sa.Integer(), nullable=True),
sa.Column('author_user_id', sa.Integer(), nullable=True),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['author_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ),
sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('broadcast_recipients',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('broadcast_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('pending', 'sent', 'failed', name='recipientstatus', native_enum=False, length=20), nullable=False),
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['broadcast_id'], ['broadcasts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('broadcast_id', 'user_id', name='uq_broadcast_user')
)
op.create_table('event_reminders',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('event_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('remind_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('login_tokens',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('token', sa.String(length=128), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('region_id', sa.Integer(), nullable=True),
sa.Column('issued_by', sa.Integer(), nullable=True),
sa.Column('status', sa.Enum('active', 'used', 'expired', 'revoked', name='logintokenstatus', native_enum=False, length=20), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['issued_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token')
)
op.create_table('region_card_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('region_id', sa.Integer(), nullable=False),
sa.Column('field', sa.String(length=100), nullable=False),
sa.Column('old_value', sa.String(length=1000), nullable=True),
sa.Column('new_value', sa.String(length=1000), nullable=True),
sa.Column('changed_by', sa.Integer(), nullable=True),
sa.Column('changed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['changed_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('region_cards',
sa.Column('region_id', sa.Integer(), nullable=False),
sa.Column('population', sa.Integer(), nullable=True),
sa.Column('head_user_id', sa.Integer(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['head_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('region_id')
)
op.create_table('region_goal_values',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('snapshot_id', sa.Integer(), nullable=False),
sa.Column('region_id', sa.Integer(), nullable=False),
sa.Column('value', sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['snapshot_id'], ['snapshots.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('snapshot_id', 'region_id', name='uq_snapshot_region')
)
op.create_table('region_links',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('region_id', sa.Integer(), nullable=False),
sa.Column('type', sa.Enum('community', 'chat', name='linktype', native_enum=False, length=20), nullable=False),
sa.Column('title', sa.String(length=200), nullable=True),
sa.Column('url', sa.String(length=500), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('requests',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('type', sa.Enum('join', 'access', 'event', 'support', name='requesttype', native_enum=False, length=20), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('region_id', sa.Integer(), nullable=True),
sa.Column('subject', sa.String(length=300), nullable=True),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('status', sa.Enum('new', 'in_progress', 'closed', name='requeststatus', native_enum=False, length=20), nullable=False),
sa.Column('assigned_to', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ),
sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('tasks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('due_date', sa.Date(), nullable=True),
sa.Column('priority', sa.Enum('low', 'medium', 'high', name='taskpriority', native_enum=False, length=20), nullable=False),
sa.Column('status', sa.Enum('new', 'in_progress', 'review', 'done', name='taskstatus', native_enum=False, length=20), nullable=False),
sa.Column('region_id', sa.Integer(), nullable=True),
sa.Column('assignee_user_id', sa.Integer(), nullable=True),
sa.Column('assignee_role', sa.Enum('admin', 'coordinator', 'region_head', 'member', 'deputy', name='rolecode', native_enum=False, length=20), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['assignee_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user_regions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('region_id', sa.Integer(), nullable=False),
sa.Column('is_primary', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'region_id', name='uq_user_region')
)
op.create_table('user_roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('region_id', sa.Integer(), nullable=True),
sa.Column('granted_by', sa.Integer(), nullable=True),
sa.Column('granted_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['granted_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'role_id', 'region_id', name='uq_user_role')
)
op.create_table('artifacts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=300), nullable=True),
sa.Column('file_key', sa.String(length=500), nullable=False),
sa.Column('file_name', sa.String(length=300), nullable=False),
sa.Column('mime_type', sa.String(length=100), nullable=True),
sa.Column('size_bytes', sa.BigInteger(), nullable=True),
sa.Column('region_id', sa.Integer(), nullable=True),
sa.Column('goal_id', sa.Integer(), nullable=True),
sa.Column('task_id', sa.Integer(), nullable=True),
sa.Column('uploaded_by', sa.Integer(), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ),
sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ),
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('request_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('request_id', sa.Integer(), nullable=False),
sa.Column('author_id', sa.Integer(), nullable=True),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['request_id'], ['requests.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('task_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('task_id', sa.Integer(), nullable=False),
sa.Column('author_id', sa.Integer(), nullable=True),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('task_comments')
op.drop_table('request_comments')
op.drop_table('artifacts')
op.drop_table('user_roles')
op.drop_table('user_regions')
op.drop_table('tasks')
op.drop_table('requests')
op.drop_table('region_links')
op.drop_table('region_goal_values')
op.drop_table('region_cards')
op.drop_table('region_card_history')
op.drop_table('login_tokens')
op.drop_table('event_reminders')
op.drop_table('broadcast_recipients')
op.drop_table('advices')
op.drop_table('snapshots')
op.drop_table('sessions')
op.drop_table('regions')
op.drop_table('platform_history')
op.drop_table('knowledge_articles')
op.drop_table('events')
op.drop_table('data_uploads')
op.drop_table('broadcasts')
op.drop_table('audit_log')
op.drop_table('assistant_configs')
op.drop_table('assistant_actions')
op.drop_table('users')
op.drop_table('roles')
op.drop_table('knowledge_categories')
op.drop_table('goals')
op.drop_table('federal_districts')
op.drop_table('broadcast_templates')
# ### end Alembic commands ###
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+74
View File
@@ -0,0 +1,74 @@
"""Зависимости FastAPI: аутентификация и авторизация."""
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from app.core.config import settings
from app.core.security import decode_access_token
from app.db.session import get_db
from app.models.enums import RoleCode
from app.models.user import Role, User, UserRole
from app.models.user import Session as UserSession
bearer = HTTPBearer(auto_error=False)
def get_current_session(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
db: DBSession = Depends(get_db),
) -> UserSession:
"""Текущая сессия по access-токену."""
if not credentials:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Требуется авторизация")
try:
payload = decode_access_token(credentials.credentials)
except Exception as exc: # noqa: BLE001
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Недействительный токен") from exc
session = db.get(UserSession, payload.get("sid"))
if not session or session.revoked_at:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Сессия завершена")
return session
def get_current_user(
session: UserSession = Depends(get_current_session),
db: DBSession = Depends(get_db),
) -> User:
"""Текущий пользователь."""
user = db.get(User, session.user_id)
if not user:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Пользователь не найден")
return user
def get_user_role_codes(db: DBSession, user_id: int) -> set[RoleCode]:
"""Множество ролей пользователя."""
rows = db.scalars(select(UserRole).where(UserRole.user_id == user_id)).all()
codes: set[RoleCode] = set()
for row in rows:
role = db.get(Role, row.role_id)
if role:
codes.add(role.code)
return codes
def require_roles(*codes: RoleCode):
"""Фабрика зависимости: доступ только для указанных ролей."""
def checker(
user: User = Depends(get_current_user),
db: DBSession = Depends(get_db),
) -> User:
if not get_user_role_codes(db, user.id).intersection(set(codes)):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Недостаточно прав")
return user
return checker
def require_service_token(x_service_token: str | None = Header(default=None)) -> None:
"""Проверка сервисного токена бота."""
if not settings.bot_service_token or x_service_token != settings.bot_service_token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Недействительный сервисный токен")
View File
Whitespace-only changes.
+60
View File
@@ -0,0 +1,60 @@
"""Эндпоинты аутентификации."""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session as DBSession
from app.api.deps import get_current_session, get_current_user, require_roles
from app.db.session import get_db
from app.models.enums import RoleCode
from app.models.user import User
from app.models.user import Session as UserSession
from app.schemas.auth import (
LoginRequest,
LoginResponse,
LoginTokenCreate,
LoginTokenOut,
RefreshRequest,
TokenPair,
UserOut,
)
from app.services import auth as auth_service
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login-token", response_model=LoginTokenOut, status_code=201)
def create_login_token(
data: LoginTokenCreate,
user: User = Depends(require_roles(RoleCode.COORDINATOR, RoleCode.ADMIN)),
db: DBSession = Depends(get_db),
) -> LoginTokenOut:
lt, url = auth_service.create_login_token(db, data, issued_by=user.id)
return LoginTokenOut(id=lt.id, url=url, expires_at=lt.expires_at)
@router.post("/login", response_model=LoginResponse)
def login(data: LoginRequest, db: DBSession = Depends(get_db)) -> LoginResponse:
tokens, user = auth_service.login(db, data)
return LoginResponse(
**tokens.model_dump(), user=auth_service.build_user_out(db, user)
)
@router.post("/refresh", response_model=TokenPair)
def refresh(data: RefreshRequest, db: DBSession = Depends(get_db)) -> TokenPair:
return auth_service.refresh(db, data.refresh_token)
@router.post("/logout", status_code=204)
def logout(
session: UserSession = Depends(get_current_session),
db: DBSession = Depends(get_db),
) -> None:
auth_service.logout(db, session.id)
@router.get("/me", response_model=UserOut)
def me(
user: User = Depends(get_current_user),
db: DBSession = Depends(get_db),
) -> UserOut:
return auth_service.build_user_out(db, user)
+9
View File
@@ -0,0 +1,9 @@
"""Служебные эндпоинты."""
from fastapi import APIRouter
router = APIRouter()
@router.get("/ping")
def ping() -> dict:
return {"pong": True}
+8
View File
@@ -0,0 +1,8 @@
"""Корневой роутер API v1."""
from fastapi import APIRouter
from app.api.v1 import auth, health
api_router = APIRouter()
api_router.include_router(health.router, tags=["system"])
api_router.include_router(auth.router)
View File
Whitespace-only changes.
+46
View File
@@ -0,0 +1,46 @@
"""Конфигурация приложения (переменные окружения)."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# Общие
app_name: str = "opora"
environment: str = "dev"
debug: bool = False
# База данных
database_url: str = "postgresql+psycopg://opora:opora@postgres:5432/opora"
# Redis / Celery
redis_url: str = "redis://redis:6379/0"
celery_broker_url: str = "redis://redis:6379/1"
celery_result_backend: str = "redis://redis:6379/2"
# JWT
jwt_secret: str = "change-me"
jwt_algorithm: str = "HS256"
access_token_ttl_days: int = 30
refresh_token_ttl_days: int = 30
# CORS
cors_origins: list[str] = ["https://opora.my-dpr.ru"]
frontend_base_url: str = "https://opora.my-dpr.ru"
# MinIO / S3
s3_endpoint: str = "http://minio:9000"
s3_access_key: str = "minio"
s3_secret_key: str = "minio123"
s3_bucket: str = "artifacts"
# Мессенджеры
telegram_bot_token: str = ""
max_bot_token: str = ""
# LLM
openrouter_api_key: str = ""
llm_model: str = "deepseek/deepseek-chat"
settings = Settings()
+36
View File
@@ -0,0 +1,36 @@
"""Утилиты безопасности: JWT и хеширование токенов."""
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from jose import jwt
from app.core.config import settings
def create_access_token(user_id: int, session_id: str) -> str:
"""Создать access-токен (JWT)."""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"sid": session_id,
"type": "access",
"iat": int(now.timestamp()),
"exp": int((now + timedelta(days=settings.access_token_ttl_days)).timestamp()),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict:
"""Декодировать и проверить access-токен."""
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
def generate_opaque_token() -> str:
"""Сгенерировать случайный opaque-токен (refresh, одноразовая ссылка)."""
return secrets.token_urlsafe(48)
def hash_token(token: str) -> str:
"""SHA-256 хеш токена (для хранения в БД)."""
return hashlib.sha256(token.encode()).hexdigest()
View File
Whitespace-only changes.
+23
View File
@@ -0,0 +1,23 @@
"""Подключение к БД и сессии SQLAlchemy."""
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
class Base(DeclarativeBase):
"""Базовый класс моделей."""
def get_db() -> Generator[Session, None, None]:
"""Зависимость FastAPI: сессия БД."""
db = SessionLocal()
try:
yield db
finally:
db.close()
+29
View File
@@ -0,0 +1,29 @@
"""Точка входа FastAPI-приложения АС «Платформа ОПОРА РОССИИ»."""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import api_router
from app.core.config import settings
app = FastAPI(
title="АС «Платформа ОПОРА РОССИИ»",
version="0.1.0",
docs_url="/api/docs",
openapi_url="/api/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix="/api/v1")
@app.get("/health", tags=["system"])
def health() -> dict:
"""Проверка работоспособности."""
return {"status": "ok"}
+49
View File
@@ -0,0 +1,49 @@
"""Модели данных (регистрация для Alembic)."""
from app.models.advice import Advice
from app.models.artifact import Artifact
from app.models.audit import AssistantAction, AssistantConfig, AuditLog
from app.models.broadcast import Broadcast, BroadcastRecipient, BroadcastTemplate
from app.models.event import Event, EventReminder
from app.models.goal import DataUpload, Goal, RegionGoalValue, Snapshot
from app.models.history import PlatformHistory
from app.models.knowledge import KnowledgeArticle, KnowledgeCategory
from app.models.region import FederalDistrict, Region
from app.models.region_card import RegionCard, RegionCardHistory, RegionLink
from app.models.request import Request, RequestComment
from app.models.task import Task, TaskComment
from app.models.user import LoginToken, Role, Session, User, UserRegion, UserRole
__all__ = [
"Advice",
"Artifact",
"AssistantAction",
"AssistantConfig",
"AuditLog",
"Broadcast",
"BroadcastRecipient",
"BroadcastTemplate",
"DataUpload",
"Event",
"EventReminder",
"FederalDistrict",
"Goal",
"KnowledgeArticle",
"KnowledgeCategory",
"LoginToken",
"PlatformHistory",
"Region",
"RegionCard",
"RegionCardHistory",
"RegionGoalValue",
"RegionLink",
"Request",
"RequestComment",
"Role",
"Session",
"Snapshot",
"Task",
"TaskComment",
"User",
"UserRegion",
"UserRole",
]
+19
View File
@@ -0,0 +1,19 @@
"""Модели советов от регионов."""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class Advice(Base):
__tablename__ = "advices"
id: Mapped[int] = mapped_column(primary_key=True)
region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE"))
goal_id: Mapped[int | None] = mapped_column(ForeignKey("goals.id"))
author_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
body: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), default="published")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+24
View File
@@ -0,0 +1,24 @@
"""Модели артефактов (файлов)."""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class Artifact(Base):
__tablename__ = "artifacts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str | None] = mapped_column(String(300))
file_key: Mapped[str] = mapped_column(String(500))
file_name: Mapped[str] = mapped_column(String(300))
mime_type: Mapped[str | None] = mapped_column(String(100))
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
region_id: Mapped[int | None] = mapped_column(ForeignKey("regions.id"))
goal_id: Mapped[int | None] = mapped_column(ForeignKey("goals.id"))
task_id: Mapped[int | None] = mapped_column(ForeignKey("tasks.id"))
uploaded_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+49
View File
@@ -0,0 +1,49 @@
"""Модели аудита и AI-ассистента."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.enums import ActorType
from app.models.types import enum_col
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(primary_key=True)
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
actor_type: Mapped[ActorType] = mapped_column(enum_col(ActorType))
action: Mapped[str] = mapped_column(String(100))
entity_type: Mapped[str | None] = mapped_column(String(100))
entity_id: Mapped[str | None] = mapped_column(String(100))
payload: Mapped[dict | None] = mapped_column(JSON)
ip: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AssistantConfig(Base):
__tablename__ = "assistant_configs"
id: Mapped[int] = mapped_column(primary_key=True)
owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
permissions: Mapped[dict | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AssistantAction(Base):
__tablename__ = "assistant_actions"
id: Mapped[int] = mapped_column(primary_key=True)
owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
action: Mapped[str] = mapped_column(String(100))
entity_type: Mapped[str | None] = mapped_column(String(100))
entity_id: Mapped[str | None] = mapped_column(String(100))
payload: Mapped[dict | None] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(20), default="ok")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+65
View File
@@ -0,0 +1,65 @@
"""Модели рассылок."""
from datetime import datetime
from sqlalchemy import (
DateTime,
ForeignKey,
JSON,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.enums import BroadcastStatus, BroadcastType, RecipientStatus
from app.models.types import enum_col
class BroadcastTemplate(Base):
__tablename__ = "broadcast_templates"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(64), unique=True)
name: Mapped[str] = mapped_column(String(200))
type: Mapped[BroadcastType] = mapped_column(enum_col(BroadcastType))
body_template: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class Broadcast(Base):
__tablename__ = "broadcasts"
id: Mapped[int] = mapped_column(primary_key=True)
template_id: Mapped[int | None] = mapped_column(ForeignKey("broadcast_templates.id"))
type: Mapped[BroadcastType] = mapped_column(enum_col(BroadcastType))
subject: Mapped[str | None] = mapped_column(String(300))
body: Mapped[str | None] = mapped_column(Text)
audience_filter: Mapped[dict | None] = mapped_column(JSON)
status: Mapped[BroadcastStatus] = mapped_column(
enum_col(BroadcastStatus), default=BroadcastStatus.DRAFT
)
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class BroadcastRecipient(Base):
__tablename__ = "broadcast_recipients"
id: Mapped[int] = mapped_column(primary_key=True)
broadcast_id: Mapped[int] = mapped_column(ForeignKey("broadcasts.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
status: Mapped[RecipientStatus] = mapped_column(
enum_col(RecipientStatus), default=RecipientStatus.PENDING
)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
error: Mapped[str | None] = mapped_column(Text)
__table_args__ = (UniqueConstraint("broadcast_id", "user_id", name="uq_broadcast_user"),)
+96
View File
@@ -0,0 +1,96 @@
"""Перечисления предметной области."""
from enum import StrEnum
class MessengerType(StrEnum):
TELEGRAM = "telegram"
MAX = "max"
class UserStatus(StrEnum):
ACTIVE = "active"
BLOCKED = "blocked"
PENDING = "pending"
class RoleCode(StrEnum):
ADMIN = "admin"
COORDINATOR = "coordinator"
REGION_HEAD = "region_head"
MEMBER = "member"
DEPUTY = "deputy"
class LoginTokenStatus(StrEnum):
ACTIVE = "active"
USED = "used"
EXPIRED = "expired"
REVOKED = "revoked"
class SnapshotKind(StrEnum):
POINT0 = "point0"
CURRENT = "current"
HISTORY = "history"
class MemberType(StrEnum):
MEMBER = "member"
RESIDENT = "resident"
class TaskStatus(StrEnum):
NEW = "new"
IN_PROGRESS = "in_progress"
REVIEW = "review"
DONE = "done"
class TaskPriority(StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class RequestType(StrEnum):
JOIN = "join"
ACCESS = "access"
EVENT = "event"
SUPPORT = "support"
class RequestStatus(StrEnum):
NEW = "new"
IN_PROGRESS = "in_progress"
CLOSED = "closed"
class BroadcastType(StrEnum):
INFO = "info"
EDUCATION = "education"
RATING = "rating"
class BroadcastStatus(StrEnum):
DRAFT = "draft"
SCHEDULED = "scheduled"
SENDING = "sending"
SENT = "sent"
FAILED = "failed"
class RecipientStatus(StrEnum):
PENDING = "pending"
SENT = "sent"
FAILED = "failed"
class ActorType(StrEnum):
USER = "user"
ASSISTANT = "assistant"
SYSTEM = "system"
class LinkType(StrEnum):
COMMUNITY = "community"
CHAT = "chat"
+33
View File
@@ -0,0 +1,33 @@
"""Модели календаря событий."""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class Event(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(300))
description: Mapped[str | None] = mapped_column(Text)
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
location: Mapped[str | None] = mapped_column(String(300))
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class EventReminder(Base):
__tablename__ = "event_reminders"
id: Mapped[int] = mapped_column(primary_key=True)
event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
remind_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+75
View File
@@ -0,0 +1,75 @@
"""Модели целей, срезов и метрик."""
from datetime import date, datetime
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
Numeric,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
from app.models.enums import SnapshotKind
from app.models.types import enum_col
class Goal(Base):
__tablename__ = "goals"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
description: Mapped[str | None] = mapped_column(String(1000))
target_value: Mapped[float | None] = mapped_column(Numeric(14, 2))
unit: Mapped[str | None] = mapped_column(String(32))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
snapshots: Mapped[list["Snapshot"]] = relationship(back_populates="goal")
class Snapshot(Base):
__tablename__ = "snapshots"
id: Mapped[int] = mapped_column(primary_key=True)
goal_id: Mapped[int] = mapped_column(ForeignKey("goals.id", ondelete="CASCADE"))
snapshot_date: Mapped[date] = mapped_column(Date)
kind: Mapped[SnapshotKind] = mapped_column(enum_col(SnapshotKind))
source: Mapped[str | None] = mapped_column(String(32))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
goal: Mapped[Goal] = relationship(back_populates="snapshots")
values: Mapped[list["RegionGoalValue"]] = relationship(back_populates="snapshot")
class RegionGoalValue(Base):
__tablename__ = "region_goal_values"
id: Mapped[int] = mapped_column(primary_key=True)
snapshot_id: Mapped[int] = mapped_column(ForeignKey("snapshots.id", ondelete="CASCADE"))
region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE"))
value: Mapped[float] = mapped_column(Numeric(14, 2))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (UniqueConstraint("snapshot_id", "region_id", name="uq_snapshot_region"),)
snapshot: Mapped[Snapshot] = relationship(back_populates="values")
class DataUpload(Base):
__tablename__ = "data_uploads"
id: Mapped[int] = mapped_column(primary_key=True)
goal_id: Mapped[int] = mapped_column(ForeignKey("goals.id"))
file_key: Mapped[str] = mapped_column(String(500))
uploaded_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
status: Mapped[str] = mapped_column(String(20), default="uploaded")
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+19
View File
@@ -0,0 +1,19 @@
"""Модели истории платформы."""
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class PlatformHistory(Base):
__tablename__ = "platform_history"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(300))
body: Mapped[str | None] = mapped_column(Text)
event_date: Mapped[date | None] = mapped_column(Date)
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+28
View File
@@ -0,0 +1,28 @@
"""Модели базы знаний."""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class KnowledgeCategory(Base):
__tablename__ = "knowledge_categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200), unique=True)
class KnowledgeArticle(Base):
__tablename__ = "knowledge_articles"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(300))
body: Mapped[str] = mapped_column(Text)
category_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_categories.id"))
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
+33
View File
@@ -0,0 +1,33 @@
"""Модели справочников: федеральные округа и регионы."""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class FederalDistrict(Base):
__tablename__ = "federal_districts"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True)
code: Mapped[str | None] = mapped_column(String(20), unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
regions: Mapped[list["Region"]] = relationship(back_populates="district")
class Region(Base):
__tablename__ = "regions"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200), unique=True)
federal_district_id: Mapped[int] = mapped_column(ForeignKey("federal_districts.id"))
status: Mapped[str] = mapped_column(String(20), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
district: Mapped[FederalDistrict] = relationship(back_populates="regions")
+46
View File
@@ -0,0 +1,46 @@
"""Модели карточки региона."""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.enums import LinkType
from app.models.types import enum_col
class RegionCard(Base):
__tablename__ = "region_cards"
region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE"), primary_key=True)
population: Mapped[int | None] = mapped_column(Integer)
head_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class RegionLink(Base):
__tablename__ = "region_links"
id: Mapped[int] = mapped_column(primary_key=True)
region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE"))
type: Mapped[LinkType] = mapped_column(enum_col(LinkType))
title: Mapped[str | None] = mapped_column(String(200))
url: Mapped[str] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class RegionCardHistory(Base):
__tablename__ = "region_card_history"
id: Mapped[int] = mapped_column(primary_key=True)
region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE"))
field: Mapped[str] = mapped_column(String(100))
old_value: Mapped[str | None] = mapped_column(String(1000))
new_value: Mapped[str | None] = mapped_column(String(1000))
changed_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+42
View File
@@ -0,0 +1,42 @@
"""Модели запросов (заявок)."""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
from app.models.enums import RequestStatus, RequestType
from app.models.types import enum_col
class Request(Base):
__tablename__ = "requests"
id: Mapped[int] = mapped_column(primary_key=True)
type: Mapped[RequestType] = mapped_column(enum_col(RequestType))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
region_id: Mapped[int | None] = mapped_column(ForeignKey("regions.id"))
subject: Mapped[str | None] = mapped_column(String(300))
body: Mapped[str | None] = mapped_column(Text)
status: Mapped[RequestStatus] = mapped_column(
enum_col(RequestStatus), default=RequestStatus.NEW
)
assigned_to: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
comments: Mapped[list["RequestComment"]] = relationship(back_populates="request")
class RequestComment(Base):
__tablename__ = "request_comments"
id: Mapped[int] = mapped_column(primary_key=True)
request_id: Mapped[int] = mapped_column(ForeignKey("requests.id", ondelete="CASCADE"))
author_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
body: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
request: Mapped[Request] = relationship(back_populates="comments")
+47
View File
@@ -0,0 +1,47 @@
"""Модели задач (трекера)."""
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
from app.models.enums import RoleCode, TaskPriority, TaskStatus
from app.models.types import enum_col
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(300))
description: Mapped[str | None] = mapped_column(Text)
due_date: Mapped[date | None] = mapped_column(Date)
priority: Mapped[TaskPriority] = mapped_column(
enum_col(TaskPriority), default=TaskPriority.MEDIUM
)
status: Mapped[TaskStatus] = mapped_column(
enum_col(TaskStatus), default=TaskStatus.NEW
)
region_id: Mapped[int | None] = mapped_column(ForeignKey("regions.id"))
assignee_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
assignee_role: Mapped[RoleCode | None] = mapped_column(enum_col(RoleCode))
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
comments: Mapped[list["TaskComment"]] = relationship(back_populates="task")
class TaskComment(Base):
__tablename__ = "task_comments"
id: Mapped[int] = mapped_column(primary_key=True)
task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"))
author_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
body: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
task: Mapped[Task] = relationship(back_populates="comments")
+17
View File
@@ -0,0 +1,17 @@
"""Вспомогательные типы для моделей."""
from enum import Enum as PyEnum
from sqlalchemy import Enum as SAEnum
def enum_col(enum_cls: type[PyEnum], length: int = 20) -> SAEnum:
"""Enum-колонка, хранящая значения (value), а не имена членов.
Например, BroadcastType.INFO хранится как 'info', а не 'INFO'.
"""
return SAEnum(
enum_cls,
native_enum=False,
length=length,
values_callable=lambda obj: [e.value for e in obj],
)
+108
View File
@@ -0,0 +1,108 @@
"""Модели пользователей, ролей и доступа."""
from datetime import date, datetime
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
from app.models.enums import LoginTokenStatus, MemberType, MessengerType, RoleCode, UserStatus
from app.models.types import enum_col
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
full_name: Mapped[str | None] = mapped_column(String(200))
messenger_type: Mapped[MessengerType] = mapped_column(enum_col(MessengerType))
messenger_id: Mapped[str] = mapped_column(String(64))
phone: Mapped[str | None] = mapped_column(String(32))
status: Mapped[UserStatus] = mapped_column(
enum_col(UserStatus), default=UserStatus.ACTIVE
)
member_type: Mapped[MemberType | None] = mapped_column(enum_col(MemberType))
joined_at: Mapped[date | None] = mapped_column(Date)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
__table_args__ = (UniqueConstraint("messenger_type", "messenger_id", name="uq_user_messenger"),)
regions: Mapped[list["UserRegion"]] = relationship(back_populates="user")
roles: Mapped[list["UserRole"]] = relationship(
back_populates="user", foreign_keys="UserRole.user_id"
)
class UserRegion(Base):
__tablename__ = "user_regions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE"))
is_primary: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (UniqueConstraint("user_id", "region_id", name="uq_user_region"),)
user: Mapped[User] = relationship(back_populates="regions")
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[RoleCode] = mapped_column(enum_col(RoleCode), unique=True)
name: Mapped[str] = mapped_column(String(100))
class UserRole(Base):
__tablename__ = "user_roles"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))
region_id: Mapped[int | None] = mapped_column(ForeignKey("regions.id"))
granted_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (UniqueConstraint("user_id", "role_id", "region_id", name="uq_user_role"),)
user: Mapped[User] = relationship(back_populates="roles", foreign_keys=[user_id])
class LoginToken(Base):
__tablename__ = "login_tokens"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
token: Mapped[str] = mapped_column(String(128), unique=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
region_id: Mapped[int | None] = mapped_column(ForeignKey("regions.id"))
issued_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
status: Mapped[LoginTokenStatus] = mapped_column(
enum_col(LoginTokenStatus), default=LoginTokenStatus.ACTIVE
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Session(Base):
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
token_hash: Mapped[str] = mapped_column(String(128), unique=True)
messenger_type: Mapped[MessengerType] = mapped_column(enum_col(MessengerType))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
View File
Whitespace-only changes.
+62
View File
@@ -0,0 +1,62 @@
"""Pydantic-схемы аутентификации."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import MessengerType
class LoginTokenCreate(BaseModel):
region_id: int | None = None
user_id: int | None = None
ttl_minutes: int = 60
class LoginTokenOut(BaseModel):
id: str
url: str
expires_at: datetime
class LoginRequest(BaseModel):
token: str
messenger_type: MessengerType
messenger_id: str
full_name: str | None = None
class RefreshRequest(BaseModel):
refresh_token: str
class TokenPair(BaseModel):
access_token: str
refresh_token: str
expires_at: datetime
class RegionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
is_primary: bool
class RoleOut(BaseModel):
code: str
region_id: int | None = None
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
full_name: str | None = None
member_type: str | None = None
regions: list[RegionOut] = []
roles: list[RoleOut] = []
class LoginResponse(TokenPair):
user: UserOut
View File
Whitespace-only changes.
+173
View File
@@ -0,0 +1,173 @@
"""Сервис аутентификации: одноразовые ссылки, вход, refresh, выход."""
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from app.core.config import settings
from app.core.security import create_access_token, generate_opaque_token, hash_token
from app.models.enums import LoginTokenStatus
from app.models.user import LoginToken, Role, User, UserRegion, UserRole
from app.models.user import Session as UserSession
from app.schemas.auth import (
LoginRequest,
LoginTokenCreate,
RegionOut,
RoleOut,
TokenPair,
UserOut,
)
def _now() -> datetime:
return datetime.now(timezone.utc)
def _as_aware(dt: datetime | None) -> datetime | None:
"""Привести naive datetime (например, из SQLite) к aware UTC."""
if dt is not None and dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def create_login_token(
db: DBSession, data: LoginTokenCreate, issued_by: int | None
) -> tuple[LoginToken, str]:
"""Создать одноразовую ссылку входа."""
token = generate_opaque_token()
expires_at = _now() + timedelta(minutes=data.ttl_minutes)
lt = LoginToken(
id=str(uuid4()),
token=token,
user_id=data.user_id,
region_id=data.region_id,
issued_by=issued_by,
expires_at=expires_at,
)
db.add(lt)
db.commit()
db.refresh(lt)
url = f"{settings.frontend_base_url}/login?token={token}"
return lt, url
def _issue_tokens(db: DBSession, user: User, messenger_type) -> TokenPair:
refresh = generate_opaque_token()
session = UserSession(
id=str(uuid4()),
user_id=user.id,
token_hash=hash_token(refresh),
messenger_type=messenger_type,
expires_at=_now() + timedelta(days=settings.refresh_token_ttl_days),
)
db.add(session)
db.commit()
access = create_access_token(user.id, session.id)
access_exp = _now() + timedelta(days=settings.access_token_ttl_days)
return TokenPair(access_token=access, refresh_token=refresh, expires_at=access_exp)
def login(db: DBSession, data: LoginRequest) -> tuple[TokenPair, User]:
"""Вход по одноразовой ссылке."""
lt = db.scalar(select(LoginToken).where(LoginToken.token == data.token))
if not lt:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ссылка не найдена")
now = _now()
if lt.status == LoginTokenStatus.USED:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Ссылка уже была использована. Запросите новую у координатора",
)
if lt.status != LoginTokenStatus.ACTIVE or _as_aware(lt.expires_at) < now:
lt.status = LoginTokenStatus.EXPIRED
db.commit()
raise HTTPException(status.HTTP_410_GONE, "Ссылка истекла")
user = db.scalar(
select(User).where(
User.messenger_type == data.messenger_type,
User.messenger_id == data.messenger_id,
)
)
if not user:
user = User(
messenger_type=data.messenger_type,
messenger_id=data.messenger_id,
full_name=data.full_name,
)
db.add(user)
db.flush()
elif data.full_name and not user.full_name:
user.full_name = data.full_name
if lt.region_id:
linked = db.scalar(
select(UserRegion).where(
UserRegion.user_id == user.id, UserRegion.region_id == lt.region_id
)
)
if not linked:
db.add(UserRegion(user_id=user.id, region_id=lt.region_id, is_primary=True))
lt.status = LoginTokenStatus.USED
lt.used_at = now
db.commit()
tokens = _issue_tokens(db, user, data.messenger_type)
return tokens, user
def refresh(db: DBSession, refresh_token: str) -> TokenPair:
"""Обновить access-токен (с ротацией refresh)."""
session = db.scalar(
select(UserSession).where(UserSession.token_hash == hash_token(refresh_token))
)
now = _now()
if not session or session.revoked_at or _as_aware(session.expires_at) < now:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Недействительный refresh-токен")
new_refresh = generate_opaque_token()
session.token_hash = hash_token(new_refresh)
db.commit()
access = create_access_token(session.user_id, session.id)
access_exp = now + timedelta(days=settings.access_token_ttl_days)
return TokenPair(access_token=access, refresh_token=new_refresh, expires_at=access_exp)
def logout(db: DBSession, session_id: str) -> None:
"""Завершить сессию."""
session = db.get(UserSession, session_id)
if session and not session.revoked_at:
session.revoked_at = _now()
db.commit()
def build_user_out(db: DBSession, user: User) -> UserOut:
"""Собрать профиль пользователя с регионами и ролями."""
from app.models.region import Region
links = db.scalars(select(UserRegion).where(UserRegion.user_id == user.id)).all()
regions = []
for link in links:
region = db.get(Region, link.region_id)
if region:
regions.append(RegionOut(id=region.id, name=region.name, is_primary=link.is_primary))
role_rows = db.scalars(select(UserRole).where(UserRole.user_id == user.id)).all()
roles = []
for r in role_rows:
role = db.get(Role, r.role_id)
if role:
roles.append(RoleOut(code=role.code.value, region_id=r.region_id))
return UserOut(
id=user.id,
full_name=user.full_name,
member_type=user.member_type.value if user.member_type else None,
regions=regions,
roles=roles,
)
+17
View File
@@ -0,0 +1,17 @@
# Backend — АС «Платформа ОПОРА РОССИИ»
fastapi==0.115.0
uvicorn[standard]==0.30.6
gunicorn==22.0.0
sqlalchemy==2.0.35
alembic==1.13.2
psycopg[binary]==3.2.3
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
celery==5.4.0
redis==5.0.8
aiogram==3.13.1
httpx==0.27.2
python-multipart==0.0.12
boto3==1.35.36
+106
View File
@@ -0,0 +1,106 @@
"""Наполнение справочников: федеральные округа, регионы, роли.
Запуск:
docker compose exec api python -m scripts.seed
"""
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.enums import RoleCode
from app.models.region import FederalDistrict, Region
from app.models.user import Role
DISTRICTS: dict[str, list[str]] = {
"ЦФО": [
"Белгородская область", "Брянская область", "Владимирская область",
"Воронежская область", "Ивановская область", "Калужская область",
"Костромская область", "Курская область", "Липецкая область",
"Московская область", "Орловская область", "Рязанская область",
"Смоленская область", "Тамбовская область", "Тверская область",
"Тульская область", "Ярославская область", "Москва",
],
"СЗФО": [
"Республика Карелия", "Республика Коми", "Архангельская область",
"Вологодская область", "Калининградская область", "Ленинградская область",
"Мурманская область", "Новгородская область", "Псковская область",
"Санкт-Петербург", "Ненецкий автономный округ",
],
"ЮФО": [
"Республика Адыгея", "Республика Калмыкия", "Республика Крым",
"Краснодарский край", "Астраханская область", "Волгоградская область",
"Ростовская область", "Севастополь",
"Донецкая Народная Республика", "Луганская Народная Республика",
"Запорожская область", "Херсонская область",
],
"СКФО": [
"Республика Дагестан", "Республика Ингушетия",
"Кабардино-Балкарская Республика", "Карачаево-Черкесская Республика",
"Республика Северная Осетия — Алания", "Чеченская Республика",
"Ставропольский край",
],
"ПФО": [
"Республика Башкортостан", "Республика Марий Эл", "Республика Мордовия",
"Республика Татарстан", "Удмуртская Республика", "Чувашская Республика",
"Пермский край", "Кировская область", "Нижегородская область",
"Оренбургская область", "Пензенская область", "Самарская область",
"Саратовская область", "Ульяновская область",
],
"УФО": [
"Курганская область", "Свердловская область", "Тюменская область",
"Челябинская область", "Ханты-Мансийский автономный округ — Югра",
"Ямало-Ненецкий автономный округ",
],
"СФО": [
"Республика Алтай", "Республика Тыва", "Республика Хакасия",
"Алтайский край", "Красноярский край", "Кемеровская область — Кузбасс",
"Иркутская область", "Новосибирская область", "Омская область",
"Томская область",
],
"ДФО": [
"Республика Бурятия", "Республика Саха (Якутия)", "Забайкальский край",
"Камчатский край", "Приморский край", "Хабаровский край",
"Амурская область", "Магаданская область", "Сахалинская область",
"Еврейская автономная область", "Чукотский автономный округ",
],
}
ROLES: list[tuple[RoleCode, str]] = [
(RoleCode.ADMIN, "Администратор платформы"),
(RoleCode.COORDINATOR, "Координатор"),
(RoleCode.REGION_HEAD, "Руководитель регионального отделения"),
(RoleCode.MEMBER, "Член Организации"),
(RoleCode.DEPUTY, "Заместитель"),
]
def seed() -> None:
db = SessionLocal()
try:
# Роли
for code, name in ROLES:
if not db.scalar(select(Role).where(Role.code == code)):
db.add(Role(code=code, name=name))
# Округа и регионы
for district_name, regions in DISTRICTS.items():
district = db.scalar(
select(FederalDistrict).where(FederalDistrict.name == district_name)
)
if not district:
district = FederalDistrict(name=district_name, code=district_name)
db.add(district)
db.flush()
for region_name in regions:
if not db.scalar(select(Region).where(Region.name == region_name)):
db.add(Region(name=region_name, federal_district_id=district.id))
db.commit()
regions_count = len(db.scalars(select(Region)).all())
districts_count = len(db.scalars(select(FederalDistrict)).all())
print(f"Справочники наполнены: округов — {districts_count}, регионов — {regions_count}.")
finally:
db.close()
if __name__ == "__main__":
seed()
+35
View File
@@ -0,0 +1,35 @@
"""Общие фикстуры для тестов."""
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///./_test.db")
os.environ.setdefault("JWT_SECRET", "test-secret")
os.environ.setdefault("BOT_SERVICE_TOKEN", "svc-token")
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
import app.models # noqa: E402,F401
from app.db.session import Base, SessionLocal, engine # noqa: E402
from app.main import app # noqa: E402
@pytest.fixture(scope="session", autouse=True)
def _setup_db():
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
@pytest.fixture()
def db():
session = SessionLocal()
try:
yield session
finally:
session.close()
@pytest.fixture()
def client():
return TestClient(app)
+108
View File
@@ -0,0 +1,108 @@
"""Тесты аутентификации."""
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from app.models.enums import MessengerType, RoleCode
from app.models.user import LoginToken, Role, User, UserRole
def _seed_admin(db) -> User:
for code, name in [(RoleCode.ADMIN, "Админ"), (RoleCode.COORDINATOR, "Координатор")]:
if not db.query(Role).filter(Role.code == code).first():
db.add(Role(code=code, name=name))
db.commit()
admin = (
db.query(User)
.filter(User.messenger_type == MessengerType.TELEGRAM, User.messenger_id == "1")
.first()
)
if not admin:
admin = User(messenger_type=MessengerType.TELEGRAM, messenger_id="1", full_name="Админ")
db.add(admin)
db.flush()
role = db.query(Role).filter(Role.code == RoleCode.ADMIN).first()
if not db.query(UserRole).filter(
UserRole.user_id == admin.id, UserRole.role_id == role.id
).first():
db.add(UserRole(user_id=admin.id, role_id=role.id))
db.commit()
return admin
def _make_login_token(db, value: str) -> None:
db.add(
LoginToken(
id=str(uuid4()),
token=value,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=30),
)
)
db.commit()
def test_login_flow(client, db):
_seed_admin(db)
_make_login_token(db, "tok-1")
# вход
r = client.post(
"/api/v1/auth/login",
json={"token": "tok-1", "messenger_type": "telegram", "messenger_id": "1"},
)
assert r.status_code == 200, r.text
data = r.json()
assert data["user"]["roles"][0]["code"] == "admin"
access, refresh = data["access_token"], data["refresh_token"]
# повторное использование ссылки
r2 = client.post(
"/api/v1/auth/login",
json={"token": "tok-1", "messenger_type": "telegram", "messenger_id": "1"},
)
assert r2.status_code == 409
# профиль
r3 = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
assert r3.status_code == 200
# выдача новой ссылки (как админ)
r4 = client.post(
"/api/v1/auth/login-token",
json={"ttl_minutes": 15},
headers={"Authorization": f"Bearer {access}"},
)
assert r4.status_code == 201, r4.text
assert "token=" in r4.json()["url"]
# refresh
r5 = client.post("/api/v1/auth/refresh", json={"refresh_token": refresh})
assert r5.status_code == 200, r5.text
new_access = r5.json()["access_token"]
# logout
r6 = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {new_access}"})
assert r6.status_code == 204
# после logout
r7 = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {new_access}"})
assert r7.status_code == 401
def test_login_token_requires_role(client, db):
_seed_admin(db)
_make_login_token(db, "tok-2")
r = client.post(
"/api/v1/auth/login",
json={"token": "tok-2", "messenger_type": "telegram", "messenger_id": "999"},
)
assert r.status_code == 200
access = r.json()["access_token"]
# обычный пользователь без роли не может выдавать ссылки
r2 = client.post(
"/api/v1/auth/login-token",
json={"ttl_minutes": 15},
headers={"Authorization": f"Bearer {access}"},
)
assert r2.status_code == 403
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "-m", "app.main"]
View File
Whitespace-only changes.
+14
View File
@@ -0,0 +1,14 @@
"""Конфигурация бота."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
telegram_bot_token: str = ""
max_bot_token: str = ""
api_base_url: str = "http://api:8000/api/v1"
bot_service_token: str = ""
settings = Settings()
+40
View File
@@ -0,0 +1,40 @@
"""Бот «ОПОРА РОССИИ»: точка входа (aiogram 3)."""
import asyncio
import logging
from aiogram import Bot, Dispatcher
from aiogram.filters import Command
from aiogram.types import Message
from app.config import settings
logging.basicConfig(level=logging.INFO)
dp = Dispatcher()
@dp.message(Command("start"))
async def cmd_start(message: Message) -> None:
await message.answer(
"Добро пожаловать в бот «ОПОРА РОССИИ»!\n\n"
"Доступные команды:\n"
"/login — войти на сайт\n"
"/goals — цели организации\n"
"/regions — регионы и пользователи\n"
"/events — регистрации на мероприятия\n"
"/help — помощь"
)
@dp.message(Command("help"))
async def cmd_help(message: Message) -> None:
await message.answer("Помощь: напишите координатору или откройте сайт платформы.")
async def main() -> None:
bot = Bot(token=settings.telegram_bot_token)
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
+3
View File
@@ -0,0 +1,3 @@
aiogram==3.13.1
httpx==0.27.2
pydantic-settings==2.5.2
+44
View File
@@ -0,0 +1,44 @@
upstream api_upstream {
server api:8000;
}
upstream web_upstream {
server web:80;
}
server {
listen 80;
server_name opora.my-dpr.ru;
# ACME challenge для Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name opora.my-dpr.ru;
ssl_certificate /etc/letsencrypt/live/opora.my-dpr.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/opora.my-dpr.ru/privkey.pem;
client_max_body_size 60m;
location /api/ {
proxy_pass http://api_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://web_upstream;
proxy_set_header Host $host;
}
}
+91
View File
@@ -0,0 +1,91 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-opora}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-opora}
POSTGRES_DB: ${POSTGRES_DB:-opora}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-opora}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redisdata:/data
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-minio}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-minio123}
volumes:
- miniodata:/data
api:
build: ./backend
restart: unless-stopped
env_file: .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
expose:
- "8000"
worker:
build: ./backend
restart: unless-stopped
command: celery -A app.tasks.celery_app worker -l info
env_file: .env
depends_on:
- redis
- postgres
beat:
build: ./backend
restart: unless-stopped
command: celery -A app.tasks.celery_app beat -l info
env_file: .env
depends_on:
- redis
bot:
build: ./bot
restart: unless-stopped
env_file: .env
depends_on:
- api
web:
build: ./frontend
restart: unless-stopped
expose:
- "80"
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./deploy/certs:/etc/letsencrypt:ro
depends_on:
- api
- web
volumes:
pgdata:
redisdata:
miniodata:
+201
View File
@@ -0,0 +1,201 @@
# Дизайн-система «ОПОРА РОССИИ»
**Проект:** АС «Платформа ОПОРА РОССИИ»
**Основа:** фирменные цвета с opora.ru (извлечены из действующего сайта)
**Дата:** 26.09.2026
**Статус:** черновик к согласованию
---
## 1. Источник фирменного стиля
Цвета извлечены из действующего сайта `opora.ru`:
| Элемент | Цвет | HEX |
|---|---|---|
| Основной (шапка, логотип) | тёмно-синий | `#091C5A` |
| Акцент (ссылки, кнопки) | синий | `#0480B9` |
| Акцент-2 (заголовки) | красный | `#E30613` |
| Фон светлый | почти белый | `#F7F8FC` |
| Текст основной | тёмно-серый | `#333333` |
| Текст вторичный | серый | `#666666` |
---
## 2. Палитра
### 2.1. Бренд
| Токен | HEX | Применение |
|---|---|---|
| `--color-primary` | `#091C5A` | шапка, основной бренд-цвет |
| `--color-primary-light` | `#1B3A8C` | hover, градиенты |
| `--color-accent` | `#0480B9` | кнопки, ссылки, активные элементы |
| `--color-accent-red` | `#E30613` | акценты, заголовки разделов |
### 2.2. Зоны (ключевое для платформы)
| Токен | HEX | Значение |
|---|---|---|
| `--color-zone-green` | `#2E7D32` | 🟢 динамика (положительный прирост) |
| `--color-zone-red` | `#C62828` | 🔴 «красная зона» (нет прироста) |
| `--color-zone-neutral` | `#9E9E9E` | ⚪ нейтральное состояние |
### 2.3. Нейтральные (светлая тема)
| Токен | HEX | Применение |
|---|---|---|
| `--bg` | `#F7F8FC` | фон страницы |
| `--surface` | `#FFFFFF` | карточки, панели |
| `--border` | `#E2E6EF` | границы |
| `--text` | `#1A1A1A` | основной текст |
| `--text-muted` | `#666666` | вторичный текст |
### 2.4. Нейтральные (тёмная тема)
| Токен | HEX | Применение |
|---|---|---|
| `--bg` | `#0E1220` | фон страницы |
| `--surface` | `#1A2033` | карточки, панели |
| `--border` | `#2A3350` | границы |
| `--text` | `#E8EAED` | основной текст |
| `--text-muted` | `#A0A6B5` | вторичный текст |
### 2.5. Семантические
| Токен | HEX | Значение |
|---|---|---|
| `--success` | `#2E7D32` | успех |
| `--warning` | `#ED6C02` | предупреждение |
| `--error` | `#C62828` | ошибка |
| `--info` | `#0480B9` | информация |
---
## 3. Типографика
| Роль | Шрифт | Размер (mobile) | Вес |
|---|---|---|---|
| H1 | **Manrope** | 24 px | 700 |
| H2 | **Manrope** | 20 px | 700 |
| H3 | **Manrope** | 17 px | 600 |
| Body | **Inter** | 15 px | 400 |
| Caption | **Inter** | 13 px | 400 |
| **Крупные цифры** | **Inter** (tabular) | 32–40 px | 700 |
- **Заголовки — Manrope, текст — Inter** (оба — open-source, self-hosted, поддержка кириллицы).
- **Табличные цифры** (`font-variant-numeric: tabular-nums`) — для показателей, чтобы не «прыгали».
- Ключевые показатели («Точка 0», «Сейчас», «Цель») — крупно, с подписями.
---
## 4. Сетка и отступы
- Базовая сетка: **4 px**.
- Отступы: 4 / 8 / 12 / 16 / 24 / 32 px.
- Горизонтальные поля экрана: **16 px**.
- Ширина контента: 360–430 px (mobile), max 1200 px (desktop).
- Минимальная зона касания: **44×44 px**.
---
## 5. Радиусы и тени
| Токен | Значение |
|---|---|
| `--radius-sm` | 8 px |
| `--radius-md` | 12 px |
| `--radius-lg` | 16 px |
| `--shadow-sm` | `0 1px 2px rgba(9,28,90,.08)` |
| `--shadow-md` | `0 4px 12px rgba(9,28,90,.12)` |
---
## 6. Компоненты
| Компонент | Описание |
|---|---|
| **Button** | primary (accent), secondary (outline), ghost; высота 48 px |
| **Card** | surface, radius-md, shadow-sm, padding 16 px |
| **Metric** | крупная цифра + подпись + зона (цвет) |
| **Badge** | зона: 🟢/🔴/⚪ |
| **Input** | высота 48 px, label сверху |
| **BottomNav** | нижняя навигация (mobile), 4–5 пунктов |
| **List item** | иконка + заголовок + подпись + шеврон |
| **Empty state** | иллюстрация/иконка + текст + действие |
| **Skeleton** | заглушка загрузки |
| **Toast** | всплывающее уведомление |
---
## 7. Цветовое кодирование зон
| Зона | Цвет | Применение |
|---|---|---|
| Динамика | 🟢 `#2E7D32` | положительный прирост |
| Красная зона | 🔴 `#C62828` | нет прироста |
| Нейтрально | ⚪ `#9E9E9E` | нет данных |
Используется в: рейтинге, топе регионов, красной зоне, карточке региона.
---
## 8. Тёмная тема
- Реализуется через CSS-переменные и `prefers-color-scheme` + ручной переключатель.
- Токены светлой/тёмной темы — раздел 2.3 / 2.4.
- Контраст текста — не ниже WCAG AA (4.5:1).
---
## 9. Логотип
- Будет предоставлен **отдельный логотип** проекта.
- До его получения используется текстовый вариант «ОПОРА РОССИИ» в фирменном цвете.
- После получения — подключить как SVG/PNG в шапку и PWA-иконки.
---
## 10. Иконки
- Набор: **Lucide** (open-source, единый стиль, поддержка React).
- Размер: 20 / 24 px.
---
## 11. Доступность
- Контраст ≥ 4.5:1 для текста, ≥ 3:1 для крупных элементов.
- Зоны дублируются не только цветом, но и текстом/иконкой (для дальтоников).
- Фокус-состояния для клавиатуры.
- Семантическая разметка, ARIA-атрибуты.
---
## 12. Технические решения
| Область | Решение |
|---|---|
| Стили | Tailwind CSS + CSS-переменные |
| Компоненты | shadcn/ui (Radix) |
| Графики | **Recharts** (React, лёгкий, поддержка кириллицы) |
| Иконки | Lucide |
| Тёмная тема | CSS-переменные + переключатель |
| PWA | манифест + service worker (установка на домашний экран) |
---
## 13. Принятые решения
1. **Палитра** — согласована.
2. **Шрифты** — Manrope (заголовки) + Inter (текст).
3. **Логотип** — будет предоставлен отдельно; до этого текстовый вариант.
4. **Тёмная тема** — да. **PWA** — да. **Графики** — Recharts.
---
## 14. Следующие шаги
1. Согласовать дизайн-систему.
2. Собрать UI-кит (компоненты) в коде.
3. Перейти к реализации каркаса проекта.
+30 -8
View File
@@ -67,14 +67,30 @@
// request // request
{ "token": "...", "messenger_type": "telegram", "messenger_id": "123456" } { "token": "...", "messenger_type": "telegram", "messenger_id": "123456" }
// response 200 // response 200
{ "access_token": "jwt", "expires_at": "...", "user": { "id": 1, "full_name": "..." } } {
"access_token": "jwt",
"refresh_token": "opaque",
"expires_at": "...",
"user": { "id": 1, "full_name": "..." }
}
``` ```
**Ошибки:** `409 link_already_used` → «Ссылка уже была использована. Запросите новую у координатора». **Ошибки:** `409 link_already_used` → «Ссылка уже была использована. Запросите новую у координатора».
### 2.3. `POST /auth/logout` — завершить сессию ### 2.3. `POST /auth/refresh` — обновить access-токен
### 2.4. `GET /auth/me` — текущий пользователь ```json
// request
{ "refresh_token": "opaque" }
// response 200
{ "access_token": "jwt", "refresh_token": "opaque", "expires_at": "..." }
```
**Правило:** refresh-токен ротируемый (при обновлении выдаётся новый, старый инвалидируется).
### 2.4. `POST /auth/logout` — завершить сессию
### 2.5. `GET /auth/me` — текущий пользователь
```json ```json
{ {
@@ -334,12 +350,18 @@
--- ---
## 17. Открытые вопросы ## 17. Принятые решения и открытые вопросы
1. **Формат JWT** — срок жизни access-токена и нужен ли refresh. **Принято:**
2. **Сервисный токен бота** — отдельный тип или роль `system`.
3. **Presigned URL** — срок жизни ссылок на скачивание артефактов. 1. **JWT** — access-токен сроком **1 месяц** + **refresh-токен** (ротируемый). Эндпоинт `POST /auth/refresh`.
4. **Вебхуки MAX** — уточнить после проверки MAX Bot API. - *Рекомендация по безопасности:* для продакшена желательно сократить access-токен (например, до 1 часа) и держать длинным refresh; при текущем решении компромисс — короткий срок хранения сессий и журналирование.
2. **Сервисный токен бота** — отдельный тип токена (системный актор), не роль пользователя.
3. **Вебхуки MAX** — проверяем после подтверждения возможностей MAX Bot API.
**Открыто:**
4. **Presigned URL** — срок жизни ссылок на скачивание артефактов (предложение: 15 минут).
--- ---
+384
View File
@@ -0,0 +1,384 @@
# Технический проект. Документ 3: Макеты экранов (mobile-first)
**Проект:** АС «Платформа ОПОРА РОССИИ»
**Принцип:** mobile-first, ширина 360–430 px (п. 4.1.6 ТЗ)
**Дата:** 26.09.2026
**Статус:** черновик к согласованию
---
## 1. Общие принципы интерфейса
- **Mobile-first:** проектирование от смартфона; ПК — адаптивное расширение.
- **Крупные элементы управления**, удобные для касания (мин. 44×44 px).
- **Ключевые показатели** — крупными цифрами с подписями: «Точка 0», «Сейчас», «Цель».
- **Цветовое кодирование зон:**
- 🟢 динамика (положительный прирост);
- 🔴 «красная зона» (нет прироста);
- ⚪ нейтральное состояние.
- **Пустые состояния** — с подсказками (например, про кнопку «Дать совет для других лидеров» в боте).
- **Единый визуальный стиль** с брендом «ОПОРА РОССИИ».
- **Язык:** русский.
---
## 2. Карта экранов
```
Вход (выбор мессенджера)
│
▼
Главная (меню)
├── Цели организации ──► Динамика ──► Топ регионов / Красная зона / Советы
├── Рейтинг регионов ──► Карточка региона
├── Задачи ──► Детали задачи
├── Артефакты
├── База знаний
├── Календарь событий
├── Запросы
├── История платформы
└── Профиль ──► Ассистент «Зам» / Выйти
Админка (отдельный доступ): Пользователи, Рассылки, Выгрузки, Аудит
```
---
## 3. Экран «Вход»
**Назначение:** вход через мессенджер по одноразовой ссылке (п. 4.2.1 ТЗ).
```
┌─────────────────────────────┐
│ ОПОРА РОССИИ │
│ │
│ Вход через рабочий бот │
│ │
│ ┌───────────────────────┐ │
│ │ Войти через MAX │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ Войти через Telegram │ │
│ └───────────────────────┘ │
│ │
│ Нет доступа? Начните │
│ регистрацию через бота │
└─────────────────────────────┘
```
**Состояния:**
- ожидание подтверждения в боте → «Подтвердите вход в боте…»;
- успех → автооткрытие личного кабинета;
- сбой автооткрытия → кнопка «Открыть сайт»;
- ссылка использована → «Ссылка уже была использована. Запросите новую у координатора».
---
## 4. Экран «Главная»
```
┌─────────────────────────────┐
│ ОПОРА РОССИИ [≡] │
│ Иванов И.И. · ДНР │
├─────────────────────────────┤
│ ┌───────────────────────┐ │
│ │ Цели организации │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ Рейтинг регионов │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ Задачи │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ Артефакты │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ База знаний │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ Календарь событий │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ Запросы │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ История платформы │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ Профиль │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
```
---
## 5. Экран «Цели организации»
**Назначение:** «что набираем, где есть движение и кому нужна помощь» (п. 4.2.4 ТЗ).
```
┌─────────────────────────────┐
│ ← Цели организации │
├─────────────────────────────┤
│ Общая цель │
│ +1096 к базе членов │
│ Округа — вкладчики в общий │
│ результат │
├─────────────────────────────┤
│ Вклад в общую цель │
│ +338 [Открыть] │
│ Численность: 12 400 │
│ Зарегистрировано: 3 200 │
├─────────────────────────────┤
│ Динамика по цели │
│ Точка 0 Сейчас Цель │
│ 1000 1338 2096 │
├─────────────────────────────┤
│ Проекты Организации │
│ Регионов с динамикой: 89 │
│ Прирост от точки 0: +2594 │
├─────────────────────────────┤
│ Топ включившихся регионов │
│ 1. Москва +224 🟢 │
│ 2. Башкортостан +217 🟢 │
│ 3. Санкт-Петербург +159 🟢 │
│ 4. ДНР +136 🟢 │
│ 5. Татарстан +132 🟢 │
├─────────────────────────────┤
│ Красная зона проектов │
│ Регионы без прироста │
│ [Полный список] │
├─────────────────────────────┤
│ Советы от регионов │
│ (пусто) Советов пока нет. │
│ Когда отделение даст │
│ динамику, в боте появится │
│ кнопка «Дать совет…» │
└─────────────────────────────┘
```
**Состояния:**
- загрузка → скелетон;
- нет проблемных регионов → «все регионы дали прирост»;
- нет советов → пустое состояние с подсказкой.
---
## 6. Экран «Динамика»
```
┌─────────────────────────────┐
│ ← Динамика │
├─────────────────────────────┤
│ Точка 0: 2026-05-08 │
│ Текущий срез: 2026-08-07 │
│ Режим: Автообновление ✅ │
├─────────────────────────────┤
│ Накопительная динамика │
│ регистраций по регионам │
│ │
│ ▁▂▃▅▆▇█ │
│ старт: 11.06.2026 │
└─────────────────────────────┘
```
---
## 7. Экран «Рейтинг регионов»
```
┌─────────────────────────────┐
│ ← Рейтинг регионов │
│ [Все округа ▼] [🔍] │
├─────────────────────────────┤
│ № Регион Прирост │
│ 1 Москва +224 🟢 │
│ 2 Башкортостан +217 🟢 │
│ 3 СПб +159 🟢 │
│ … │
│ N Регион X 0 🔴 │
├─────────────────────────────┤
│ Сортировка: по приросту ▼ │
└─────────────────────────────┘
```
**Фильтры:** округ, «красная зона», сортировка (прирост / численность).
---
## 8. Экран «Карточка региона»
```
┌─────────────────────────────┐
│ ← Донецкая Народная Респ. │
│ Округ: ЮФО · Статус: актив.│
├─────────────────────────────┤
│ Численность: 1 200 [✎] │
│ Члены: 340 · Резиденты: 45 │
├─────────────────────────────┤
│ Ссылки на сообщества/чаты │
│ • Чат ДНР [→] │
│ • Сообщество VK [→] │
│ [+ Добавить ссылку] │
├─────────────────────────────┤
│ Члены │
│ [Список] [+ Добавить] │
├─────────────────────────────┤
│ Резиденты │
│ [Список] [+ Добавить] │
├─────────────────────────────┤
│ История изменений │
│ [Показать] │
└─────────────────────────────┘
```
**Права:** руководитель редактирует только своё отделение; админ — все (п. 4.2.6.1, Вариант 1).
---
## 9. Экран «Задачи»
```
┌─────────────────────────────┐
│ ← Задачи [+ Создать]│
│ [Мои] [Регион] [Просроч.] │
├─────────────────────────────┤
│ ● Подготовить отчёт │
│ до 01.10 · высокий │
│ статус: в работе │
│ ● Собрать данные │
│ до 05.10 · средний │
│ статус: новая │
└─────────────────────────────┘
```
**Детали задачи:** название, описание, срок, приоритет, ответственный, статус (`новая → в работе → на проверке → выполнена`), комментарии, артефакты.
---
## 10. Экран «Артефакты»
```
┌─────────────────────────────┐
│ ← Артефакты [↑ Загрузить]│
│ [Все] [Регион] [Цель] [Задача]│
├─────────────────────────────┤
│ 📄 Отчёт_ДНР.pdf │
│ 1.2 МБ · 26.09.2026 │
│ 🖼 Фото_мероприятие.jpg │
│ 3.4 МБ · 25.09.2026 │
└─────────────────────────────┘
```
**Ограничения:** до 50 МБ; изображения, PDF, DOCX, XLSX.
---
## 11. Экран «База знаний»
```
┌─────────────────────────────┐
│ ← База знаний [🔍] │
│ [Все] [Инструкции] [Мат-лы]│
├─────────────────────────────┤
│ 📘 Как работать с целями │
│ 📘 Регламент рассылок │
│ 📘 Методика советов │
└─────────────────────────────┘
```
---
## 12. Экран «Календарь событий»
```
┌─────────────────────────────┐
│ ← Календарь [< Сентябрь >]│
├─────────────────────────────┤
│ Пн Вт Ср Чт Пт Сб Вс │
│ … … … … … … … │
├─────────────────────────────┤
│ 26.09 · Вебинар «Цели» │
│ 30.09 · Дедлайн отчётов │
└─────────────────────────────┘
```
**Напоминания** приходят через бота.
---
## 13. Экран «Запросы»
```
┌─────────────────────────────┐
│ ← Запросы [+ Создать]│
│ [Мои] [Все] │
├─────────────────────────────┤
│ ● Заявка на вступление │
│ статус: новая │
│ ● Запрос на поддержку │
│ статус: в работе │
└─────────────────────────────┘
```
**Типы:** вступление, доступ, участие в мероприятии, поддержка.
---
## 14. Экран «Профиль»
```
┌─────────────────────────────┐
│ ← Профиль │
├─────────────────────────────┤
│ Иванов И.И. │
│ Регион: ДНР │
│ Роль: руководитель │
├─────────────────────────────┤
│ Ассистент «Зам» │
│ Статус: подключён ✅ │
│ [Настроить] [Журнал] │
├─────────────────────────────┤
│ [Выйти] │
└─────────────────────────────┘
```
---
## 15. Админка (отдельный доступ)
| Раздел | Содержание |
|---|---|
| Пользователи | список, роли, статусы, регионы |
| Рассылки | шаблоны, запуск, прогресс доставки |
| Выгрузки | загрузка срезов, статус обработки |
| Аудит | журнал действий пользователей и ассистента |
---
## 16. Состояния и обратная связь
| Состояние | Отображение |
|---|---|
| Загрузка | скелетоны, спиннер |
| Пусто | подсказка с действием |
| Ошибка | сообщение + «Повторить» |
| Нет прав | «Недостаточно прав» + возврат |
| Оффлайн | баннер «Нет соединения» |
---
## 17. Принятые решения
1. **Брендбук** — отсутствует; разработана дизайн-система на основе фирменных цветов opora.ru (см. `docs/design/design-system.md`).
2. **Графики** — **Recharts**.
3. **Тёмная тема** — **нужна** (CSS-переменные + переключатель).
4. **PWA** — **нужна** (установка на домашний экран).
---
## 18. Следующие шаги
1. Согласовать макеты.
2. Перейти к реализации: каркас проекта (FastAPI + React), Docker Compose, CI/CD.
+13
View File
@@ -0,0 +1,13 @@
# build
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# serve
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#091C5A" />
<title>ОПОРА РОССИИ</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "opora-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"lucide-react": "^0.446.0"
},
"devDependencies": {
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite": "^5.4.8",
"vite-plugin-pwa": "^0.20.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+40
View File
@@ -0,0 +1,40 @@
import { Link, Route, Routes } from "react-router-dom";
function Home() {
return (
<main className="mx-auto max-w-[430px] p-4">
<header className="mb-6">
<h1 className="font-heading text-2xl font-bold text-primary">ОПОРА РОССИИ</h1>
<p className="text-sm text-muted">Платформа</p>
</header>
<nav className="grid gap-3">
<Link className="card" to="/goals">Цели организации</Link>
<Link className="card" to="/ratings">Рейтинг регионов</Link>
<Link className="card" to="/tasks">Задачи</Link>
<Link className="card" to="/artifacts">Артефакты</Link>
</nav>
</main>
);
}
function Placeholder({ title }: { title: string }) {
return (
<main className="mx-auto max-w-[430px] p-4">
<Link to="/" className="text-accent">← Назад</Link>
<h1 className="font-heading mt-4 text-xl font-bold">{title}</h1>
<p className="mt-2 text-muted">Раздел в разработке.</p>
</main>
);
}
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/goals" element={<Placeholder title="Цели организации" />} />
<Route path="/ratings" element={<Placeholder title="Рейтинг регионов" />} />
<Route path="/tasks" element={<Placeholder title="Задачи" />} />
<Route path="/artifacts" element={<Placeholder title="Артефакты" />} />
</Routes>
);
}
+52
View File
@@ -0,0 +1,52 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-primary: #091c5a;
--color-primary-light: #1b3a8c;
--color-accent: #0480b9;
--color-accent-red: #e30613;
--color-zone-green: #2e7d32;
--color-zone-red: #c62828;
--color-zone-neutral: #9e9e9e;
--bg: #f7f8fc;
--surface: #ffffff;
--border: #e2e6ef;
--text: #1a1a1a;
--text-muted: #666666;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1220;
--surface: #1a2033;
--border: #2a3350;
--text: #e8eaed;
--text-muted: #a0a6b5;
}
}
body {
background: var(--bg);
color: var(--text);
font-family: Inter, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
.font-heading {
font-family: Manrope, Inter, system-ui, sans-serif;
}
.card {
display: block;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 2px rgba(9, 28, 90, 0.08);
}
.text-primary { color: var(--color-primary); }
.text-accent { color: var(--color-accent); }
.text-muted { color: var(--text-muted); }
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+22
View File
@@ -0,0 +1,22 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
primary: "#091C5A",
"primary-light": "#1B3A8C",
accent: "#0480B9",
"accent-red": "#E30613",
"zone-green": "#2E7D32",
"zone-red": "#C62828",
"zone-neutral": "#9E9E9E",
},
fontFamily: {
heading: ["Manrope", "Inter", "system-ui", "sans-serif"],
body: ["Inter", "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
manifest: {
name: "ОПОРА РОССИИ",
short_name: "ОПОРА",
lang: "ru",
theme_color: "#091C5A",
background_color: "#F7F8FC",
display: "standalone",
start_url: "/",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
],
},
}),
],
server: {
host: true,
port: 5173,
proxy: {
"/api": { target: "http://api:8000", changeOrigin: true },
},
},
});