feat: project scaffold (FastAPI backend, React frontend, aiogram bot, docker-compose, CI/CD)
This commit is contained in:
1 parent
066deaca47
commit
71460ab56a
37 files changed
+936
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
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# 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
|
||||||
@@ -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()
|
||||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,9 @@
|
|||||||
|
"""Служебные эндпоинты."""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ping")
|
||||||
|
def ping() -> dict:
|
||||||
|
return {"pong": True}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Корневой роутер API v1."""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import health
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(health.router, tags=["system"])
|
||||||
Whitespace-only changes.
@@ -0,0 +1,45 @@
|
|||||||
|
"""Конфигурация приложения (переменные окружения)."""
|
||||||
|
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"]
|
||||||
|
|
||||||
|
# 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()
|
||||||
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,4 @@
|
|||||||
|
"""Модели данных."""
|
||||||
|
from app.models.region import FederalDistrict, Region
|
||||||
|
|
||||||
|
__all__ = ["FederalDistrict", "Region"]
|
||||||
@@ -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,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,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,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