Compare commits
5
Commits
615b466c72
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
049b679654 | ||
|
|
4a610ba94a | ||
|
|
71460ab56a | ||
|
|
066deaca47 | ||
|
|
5b09839246 |
No files matched your search
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
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 ###
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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, "Недействительный сервисный токен")
|
||||||
Whitespace-only changes.
@@ -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)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Служебные эндпоинты."""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ping")
|
||||||
|
def ping() -> dict:
|
||||||
|
return {"pong": True}
|
||||||
@@ -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)
|
||||||
Whitespace-only changes.
@@ -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()
|
||||||
@@ -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()
|
||||||
Whitespace-only changes.
@@ -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()
|
||||||
@@ -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"}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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"),)
|
||||||
@@ -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"
|
||||||
@@ -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))
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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())
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
Whitespace-only changes.
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
Whitespace-only changes.
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
aiogram==3.13.1
|
||||||
|
httpx==0.27.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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. Перейти к реализации каркаса проекта.
|
||||||
@@ -369,12 +369,12 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 17. Открытые вопросы
|
## 17. Принятые решения
|
||||||
|
|
||||||
1. **Брендбук** — есть ли гайдлайны «ОПОРА РОССИИ» (цвета, шрифты, логотип)?
|
1. **Брендбук** — отсутствует; разработана дизайн-система на основе фирменных цветов opora.ru (см. `docs/design/design-system.md`).
|
||||||
2. **Графики** — библиотека визуализации (Recharts / Chart.js).
|
2. **Графики** — **Recharts**.
|
||||||
3. **Тёмная тема** — нужна ли.
|
3. **Тёмная тема** — **нужна** (CSS-переменные + переключатель).
|
||||||
4. **PWA** — нужна ли установка на домашний экран.
|
4. **PWA** — **нужна** (установка на домашний экран).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user