From 112ab43b340ecbf0e691f002c654aa407c978bb3 Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:57:49 +0300 Subject: [PATCH] first commit --- .idea/.gitignore | 10 + .idea/Law.iml | 8 + Dockerfile | 8 + Makefile | 8 + README.md | 17 + alembic.ini | 36 + alembic/env.py | 44 + alembic/versions/0001_init.py | 179 +++ app/__init__.py | 0 app/api/admin/auth.py | 19 + app/api/admin/config.py | 182 +++ app/api/admin/meta.py | 18 + app/api/admin/metrics.py | 20 + app/api/admin/quotes.py | 55 + app/api/admin/requests.py | 110 ++ app/api/admin/router.py | 11 + app/api/admin/uploads.py | 12 + app/api/public/otp.py | 25 + app/api/public/quotes.py | 17 + app/api/public/requests.py | 16 + app/api/public/router.py | 8 + app/api/public/uploads.py | 10 + app/core/config.py | 40 + app/core/deps.py | 29 + app/core/security.py | 20 + app/db/session.py | 16 + app/main.py | 30 + app/models/__init__.py | 0 app/models/admin_user.py | 12 + app/models/attachment.py | 16 + app/models/audit_log.py | 14 + app/models/common.py | 15 + app/models/form_field.py | 14 + app/models/message.py | 14 + app/models/otp_session.py | 17 + app/models/quote.py | 12 + app/models/request.py | 16 + app/models/status.py | 12 + app/models/status_history.py | 14 + app/models/topic.py | 11 + app/schemas/admin.py | 66 + app/schemas/public.py | 26 + app/schemas/universal.py | 23 + app/services/universal_query.py | 29 + app/web/admin.html | 675 +++++++++ app/web/admin.jsx | 1846 +++++++++++++++++++++++++ app/web/landing.html | 833 +++++++++++ app/workers/celery_app.py | 12 + app/workers/tasks/assign.py | 5 + app/workers/tasks/security.py | 5 + app/workers/tasks/sla.py | 5 + app/workers/tasks/uploads.py | 5 + celerybeat-schedule | Bin 0 -> 16384 bytes context/00_system_overview.md | 19 + context/01_public_requests_service.md | 18 + context/02_otp_service.md | 18 + context/03_admin_panel_service.md | 17 + context/04_files_service.md | 12 + context/05_sla_auto_assign_service.md | 23 + context/06_quotes_service.md | 20 + context/07_universal_query_engine.md | 20 + context/08_security_model.md | 16 + context/09_metrics_dashboard.md | 14 + docker-compose.yml | 61 + docs/architecture_fastapi_celery.md | 18 + docs/openapi.yaml | 41 + frontend/Dockerfile | 4 + frontend/nginx.conf | 35 + requirements.txt | 14 + tests/test_migrations.py | 101 ++ tests/test_public_requests.py | 87 ++ 71 files changed, 5183 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/Law.iml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/versions/0001_init.py create mode 100644 app/__init__.py create mode 100644 app/api/admin/auth.py create mode 100644 app/api/admin/config.py create mode 100644 app/api/admin/meta.py create mode 100644 app/api/admin/metrics.py create mode 100644 app/api/admin/quotes.py create mode 100644 app/api/admin/requests.py create mode 100644 app/api/admin/router.py create mode 100644 app/api/admin/uploads.py create mode 100644 app/api/public/otp.py create mode 100644 app/api/public/quotes.py create mode 100644 app/api/public/requests.py create mode 100644 app/api/public/router.py create mode 100644 app/api/public/uploads.py create mode 100644 app/core/config.py create mode 100644 app/core/deps.py create mode 100644 app/core/security.py create mode 100644 app/db/session.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/admin_user.py create mode 100644 app/models/attachment.py create mode 100644 app/models/audit_log.py create mode 100644 app/models/common.py create mode 100644 app/models/form_field.py create mode 100644 app/models/message.py create mode 100644 app/models/otp_session.py create mode 100644 app/models/quote.py create mode 100644 app/models/request.py create mode 100644 app/models/status.py create mode 100644 app/models/status_history.py create mode 100644 app/models/topic.py create mode 100644 app/schemas/admin.py create mode 100644 app/schemas/public.py create mode 100644 app/schemas/universal.py create mode 100644 app/services/universal_query.py create mode 100644 app/web/admin.html create mode 100644 app/web/admin.jsx create mode 100644 app/web/landing.html create mode 100644 app/workers/celery_app.py create mode 100644 app/workers/tasks/assign.py create mode 100644 app/workers/tasks/security.py create mode 100644 app/workers/tasks/sla.py create mode 100644 app/workers/tasks/uploads.py create mode 100644 celerybeat-schedule create mode 100644 context/00_system_overview.md create mode 100644 context/01_public_requests_service.md create mode 100644 context/02_otp_service.md create mode 100644 context/03_admin_panel_service.md create mode 100644 context/04_files_service.md create mode 100644 context/05_sla_auto_assign_service.md create mode 100644 context/06_quotes_service.md create mode 100644 context/07_universal_query_engine.md create mode 100644 context/08_security_model.md create mode 100644 context/09_metrics_dashboard.md create mode 100644 docker-compose.yml create mode 100644 docs/architecture_fastapi_celery.md create mode 100644 docs/openapi.yaml create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf create mode 100644 requirements.txt create mode 100644 tests/test_migrations.py create mode 100644 tests/test_public_requests.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/Law.iml b/.idea/Law.iml new file mode 100644 index 0000000..8388dbc --- /dev/null +++ b/.idea/Law.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3ad36e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV PYTHONUNBUFFERED=1 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e4f125b --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +run: + docker compose up --build + +migrate: + docker compose exec backend alembic upgrade head + +test: + docker compose exec backend python -m unittest discover -s tests -p "test_*.py" -v diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a9b1ea --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Legal Case Tracker (FastAPI) +Backend skeleton: public requests + OTP + public JWT cookie + admin (admin/lawyer) + files (self-hosted S3) + SLA/auto-assign (Celery) + quotes. + +## Run (Docker) +```bash +cp .env.example .env +docker compose up --build +``` +Landing (frontend): http://localhost:8081 +Admin UI: http://localhost:8081/admin +API (backend): http://localhost:8002 +Swagger: http://localhost:8002/docs + +## Migrations +```bash +docker compose exec backend alembic upgrade head +``` diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..49e2caa --- /dev/null +++ b/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = +prepend_sys_path = . + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[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 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..354759f --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,44 @@ +from logging.config import fileConfig +from alembic import context +from sqlalchemy import engine_from_config, pool +import os +from app.db.session import Base + +# import models +from app.models.admin_user import AdminUser +from app.models.topic import Topic +from app.models.status import Status +from app.models.form_field import FormField +from app.models.request import Request +from app.models.message import Message +from app.models.attachment import Attachment +from app.models.status_history import StatusHistory +from app.models.audit_log import AuditLog +from app.models.otp_session import OtpSession +from app.models.quote import Quote + +config = context.config +fileConfig(config.config_file_name) +target_metadata = Base.metadata + +def get_url(): + return os.getenv("DATABASE_URL") + +def run_migrations_offline(): + context.configure(url=get_url(), target_metadata=target_metadata, literal_binds=True, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + +def run_migrations_online(): + cfg = config.get_section(config.config_ini_section) + cfg["sqlalchemy.url"] = get_url() + connectable = engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/versions/0001_init.py b/alembic/versions/0001_init.py new file mode 100644 index 0000000..73d2ab3 --- /dev/null +++ b/alembic/versions/0001_init.py @@ -0,0 +1,179 @@ +"""init +Revision ID: 0001_init +Revises: +Create Date: 2026-02-21 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0001_init" +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + op.create_table( + "admin_users", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("email", sa.String(length=200), nullable=False, unique=True), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + ) + + op.create_table( + "topics", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("code", sa.String(length=50), nullable=False, unique=True), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + ) + + op.create_table( + "statuses", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("code", sa.String(length=50), nullable=False, unique=True), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_terminal", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + + op.create_table( + "form_fields", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("key", sa.String(length=80), nullable=False, unique=True), + sa.Column("label", sa.String(length=200), nullable=False), + sa.Column("type", sa.String(length=30), nullable=False), + sa.Column("required", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("options", sa.JSON(), nullable=True), + ) + + op.create_table( + "requests", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("track_number", sa.String(length=40), nullable=False, unique=True), + sa.Column("client_name", sa.String(length=200), nullable=False), + sa.Column("client_phone", sa.String(length=30), nullable=False), + sa.Column("topic_code", sa.String(length=50), nullable=True), + sa.Column("status_code", sa.String(length=50), nullable=False, server_default="NEW"), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("extra_fields", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")), + sa.Column("assigned_lawyer_id", sa.String(length=64), nullable=True), + sa.Column("total_attachments_bytes", sa.Integer(), nullable=False, server_default="0"), + ) + op.create_index("ix_requests_track_number", "requests", ["track_number"]) + op.create_index("ix_requests_phone", "requests", ["client_phone"]) + op.create_index("ix_requests_status", "requests", ["status_code"]) + + op.create_table( + "messages", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("author_type", sa.String(length=20), nullable=False), + sa.Column("author_name", sa.String(length=200), nullable=True), + sa.Column("body", sa.Text(), nullable=True), + sa.Column("immutable", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + op.create_index("ix_messages_request_id", "messages", ["request_id"]) + + op.create_table( + "attachments", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("message_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("file_name", sa.String(length=300), nullable=False), + sa.Column("mime_type", sa.String(length=150), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("s3_key", sa.String(length=500), nullable=False), + sa.Column("immutable", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + op.create_index("ix_attachments_request_id", "attachments", ["request_id"]) + + op.create_table( + "status_history", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("from_status", sa.String(length=50), nullable=True), + sa.Column("to_status", sa.String(length=50), nullable=False), + sa.Column("changed_by_admin_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("comment", sa.String(length=400), nullable=True), + ) + op.create_index("ix_status_history_request_id", "status_history", ["request_id"]) + + op.create_table( + "audit_log", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("actor_admin_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("entity", sa.String(length=80), nullable=False), + sa.Column("entity_id", sa.String(length=80), nullable=False), + sa.Column("action", sa.String(length=30), nullable=False), + sa.Column("diff", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")), + ) + + op.create_table( + "otp_sessions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("purpose", sa.String(length=30), nullable=False), + sa.Column("track_number", sa.String(length=40), nullable=True), + sa.Column("phone", sa.String(length=30), nullable=False), + sa.Column("code_hash", sa.String(length=255), nullable=False), + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_otp_phone", "otp_sessions", ["phone"]) + op.create_index("ix_otp_track", "otp_sessions", ["track_number"]) + + op.create_table( + "quotes", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("text", sa.Text(), nullable=False), + sa.Column("author", sa.String(length=200), nullable=False), + sa.Column("source", sa.String(length=400), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + ) + +def downgrade(): + op.drop_table("quotes") + op.drop_table("otp_sessions") + op.drop_table("audit_log") + op.drop_table("status_history") + op.drop_table("attachments") + op.drop_table("messages") + op.drop_index("ix_requests_status", table_name="requests") + op.drop_index("ix_requests_phone", table_name="requests") + op.drop_index("ix_requests_track_number", table_name="requests") + op.drop_table("requests") + op.drop_table("form_fields") + op.drop_table("statuses") + op.drop_table("topics") + op.drop_table("admin_users") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/admin/auth.py b/app/api/admin/auth.py new file mode 100644 index 0000000..8737005 --- /dev/null +++ b/app/api/admin/auth.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, HTTPException, Depends +from datetime import timedelta +from sqlalchemy.orm import Session +from app.schemas.admin import AdminLogin, AdminToken +from app.core.security import create_jwt, verify_password +from app.core.config import settings +from app.db.session import get_db +from app.models.admin_user import AdminUser + +router = APIRouter() + +@router.post("/login", response_model=AdminToken) +def login(payload: AdminLogin, db: Session = Depends(get_db)): + user = db.query(AdminUser).filter(AdminUser.email == payload.email, AdminUser.is_active == True).first() + if not user or not verify_password(payload.password, user.password_hash): + raise HTTPException(status_code=401, detail="Неверный логин или пароль") + token = create_jwt({"sub": str(user.id), "email": user.email, "role": user.role}, + settings.ADMIN_JWT_SECRET, timedelta(minutes=settings.ADMIN_JWT_TTL_MINUTES)) + return AdminToken(access_token=token) diff --git a/app/api/admin/config.py b/app/api/admin/config.py new file mode 100644 index 0000000..ff752d1 --- /dev/null +++ b/app/api/admin/config.py @@ -0,0 +1,182 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from app.db.session import get_db +from app.core.deps import require_role +from app.schemas.universal import UniversalQuery +from app.schemas.admin import TopicUpsert, StatusUpsert, FormFieldUpsert +from app.models.topic import Topic +from app.models.status import Status +from app.models.form_field import FormField +from app.services.universal_query import apply_universal_query + +router = APIRouter() + + +def _topic_row(row: Topic): + return {"id": str(row.id), "code": row.code, "name": row.name, "enabled": row.enabled, "sort_order": row.sort_order} + + +def _status_row(row: Status): + return { + "id": str(row.id), + "code": row.code, + "name": row.name, + "enabled": row.enabled, + "sort_order": row.sort_order, + "is_terminal": row.is_terminal, + } + + +def _form_field_row(row: FormField): + return { + "id": str(row.id), + "key": row.key, + "label": row.label, + "type": row.type, + "required": row.required, + "enabled": row.enabled, + "sort_order": row.sort_order, + "options": row.options, + } + +@router.post("/topics/query") +def query_topics(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + q = apply_universal_query(db.query(Topic), Topic, uq) + total = q.count() + rows = q.offset(uq.page.offset).limit(uq.page.limit).all() + return {"rows": [_topic_row(r) for r in rows], "total": total} + + +@router.post("/topics", status_code=201) +def create_topic(payload: TopicUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + row = Topic(**payload.model_dump()) + try: + db.add(row) + db.commit() + db.refresh(row) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=400, detail="Тема с таким кодом уже существует") + return _topic_row(row) + + +@router.patch("/topics/{id}") +def update_topic(id: str, payload: TopicUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + row = db.query(Topic).filter(Topic.id == id).first() + if not row: + raise HTTPException(status_code=404, detail="Тема не найдена") + for k, v in payload.model_dump().items(): + setattr(row, k, v) + try: + db.add(row) + db.commit() + db.refresh(row) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=400, detail="Тема с таким кодом уже существует") + return _topic_row(row) + + +@router.delete("/topics/{id}") +def delete_topic(id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + row = db.query(Topic).filter(Topic.id == id).first() + if not row: + raise HTTPException(status_code=404, detail="Тема не найдена") + db.delete(row) + db.commit() + return {"status": "удалено"} + +@router.post("/statuses/query") +def query_statuses(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + q = apply_universal_query(db.query(Status), Status, uq) + total = q.count() + rows = q.offset(uq.page.offset).limit(uq.page.limit).all() + return {"rows": [_status_row(r) for r in rows], "total": total} + + +@router.post("/statuses", status_code=201) +def create_status(payload: StatusUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + row = Status(**payload.model_dump()) + try: + db.add(row) + db.commit() + db.refresh(row) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=400, detail="Статус с таким кодом уже существует") + return _status_row(row) + + +@router.patch("/statuses/{id}") +def update_status(id: str, payload: StatusUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + row = db.query(Status).filter(Status.id == id).first() + if not row: + raise HTTPException(status_code=404, detail="Статус не найден") + for k, v in payload.model_dump().items(): + setattr(row, k, v) + try: + db.add(row) + db.commit() + db.refresh(row) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=400, detail="Статус с таким кодом уже существует") + return _status_row(row) + + +@router.delete("/statuses/{id}") +def delete_status(id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + row = db.query(Status).filter(Status.id == id).first() + if not row: + raise HTTPException(status_code=404, detail="Статус не найден") + db.delete(row) + db.commit() + return {"status": "удалено"} + +@router.post("/form-fields/query") +def query_form_fields(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + q = apply_universal_query(db.query(FormField), FormField, uq) + total = q.count() + rows = q.offset(uq.page.offset).limit(uq.page.limit).all() + return {"rows": [_form_field_row(r) for r in rows], "total": total} + + +@router.post("/form-fields", status_code=201) +def create_form_field(payload: FormFieldUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + row = FormField(**payload.model_dump()) + try: + db.add(row) + db.commit() + db.refresh(row) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=400, detail="Поле формы с таким ключом уже существует") + return _form_field_row(row) + + +@router.patch("/form-fields/{id}") +def update_form_field(id: str, payload: FormFieldUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + row = db.query(FormField).filter(FormField.id == id).first() + if not row: + raise HTTPException(status_code=404, detail="Поле формы не найдено") + for k, v in payload.model_dump().items(): + setattr(row, k, v) + try: + db.add(row) + db.commit() + db.refresh(row) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=400, detail="Поле формы с таким ключом уже существует") + return _form_field_row(row) + + +@router.delete("/form-fields/{id}") +def delete_form_field(id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + row = db.query(FormField).filter(FormField.id == id).first() + if not row: + raise HTTPException(status_code=404, detail="Поле формы не найдено") + db.delete(row) + db.commit() + return {"status": "удалено"} diff --git a/app/api/admin/meta.py b/app/api/admin/meta.py new file mode 100644 index 0000000..7057073 --- /dev/null +++ b/app/api/admin/meta.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Depends +from app.core.deps import require_role + +router = APIRouter() + +META = { + "quotes": [ + {"field_name": "author", "label": "Автор", "type": "string", "required": True, "read_only": False, "editable_roles": ["ADMIN"]}, + {"field_name": "text", "label": "Цитата", "type": "text", "required": True, "read_only": False, "editable_roles": ["ADMIN"]}, + {"field_name": "source", "label": "Источник", "type": "string", "required": False, "read_only": False, "editable_roles": ["ADMIN"]}, + {"field_name": "is_active", "label": "Активна", "type": "boolean", "required": False, "read_only": False, "editable_roles": ["ADMIN"]}, + {"field_name": "sort_order", "label": "Порядок", "type": "number", "required": False, "read_only": False, "editable_roles": ["ADMIN"]}, + ] +} + +@router.get("/{entity}") +def get_entity_meta(entity: str, admin=Depends(require_role("ADMIN","LAWYER"))): + return {"entity": entity, "fields": META.get(entity, [])} diff --git a/app/api/admin/metrics.py b/app/api/admin/metrics.py new file mode 100644 index 0000000..600b75f --- /dev/null +++ b/app/api/admin/metrics.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import func +from sqlalchemy.orm import Session +from app.core.deps import require_role +from app.db.session import get_db +from app.models.request import Request + +router = APIRouter() + +@router.get("/overview") +def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))): + by_status_rows = db.query(Request.status_code, func.count(Request.id)).group_by(Request.status_code).all() + by_status = {status: count for status, count in by_status_rows} + return { + "new": by_status.get("NEW", 0), + "by_status": by_status, + "frt_avg_minutes": None, + "sla_overdue": 0, + "avg_time_in_status_hours": {}, + } diff --git a/app/api/admin/quotes.py b/app/api/admin/quotes.py new file mode 100644 index 0000000..95f5b6e --- /dev/null +++ b/app/api/admin/quotes.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.core.deps import require_role +from app.schemas.universal import UniversalQuery +from app.schemas.admin import QuoteUpsert +from app.models.quote import Quote +from app.services.universal_query import apply_universal_query + +router = APIRouter() + +@router.post("/query") +def query_quotes(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + q = apply_universal_query(db.query(Quote), Quote, uq) + total = q.count() + rows = q.offset(uq.page.offset).limit(uq.page.limit).all() + return { + "rows": [ + { + "id": str(r.id), + "author": r.author, + "text": r.text, + "source": r.source, + "is_active": r.is_active, + "sort_order": r.sort_order, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + for r in rows + ], + "total": total, + } + +@router.post("", status_code=201) +def create_quote(payload: QuoteUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + q = Quote(**payload.model_dump()) + db.add(q); db.commit(); db.refresh(q) + return {"id": str(q.id)} + +@router.patch("/{id}") +def update_quote(id: str, payload: QuoteUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + q = db.query(Quote).filter(Quote.id == id).first() + if not q: + raise HTTPException(status_code=404, detail="Цитата не найдена") + for k, v in payload.model_dump().items(): + setattr(q, k, v) + db.add(q); db.commit() + return {"status": "обновлено"} + +@router.delete("/{id}") +def delete_quote(id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + q = db.query(Quote).filter(Quote.id == id).first() + if not q: + raise HTTPException(status_code=404, detail="Цитата не найдена") + db.delete(q); db.commit() + return {"status": "удалено"} diff --git a/app/api/admin/requests.py b/app/api/admin/requests.py new file mode 100644 index 0000000..e90fb9f --- /dev/null +++ b/app/api/admin/requests.py @@ -0,0 +1,110 @@ +from uuid import uuid4 +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from app.db.session import get_db +from app.core.deps import require_role +from app.schemas.universal import UniversalQuery +from app.schemas.admin import RequestAdminCreate, RequestAdminPatch +from app.models.request import Request +from app.services.universal_query import apply_universal_query + +router = APIRouter() + +@router.post("/query") +def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))): + q = apply_universal_query(db.query(Request), Request, uq) + total = q.count() + rows = q.offset(uq.page.offset).limit(uq.page.limit).all() + return { + "rows": [ + { + "id": str(r.id), + "track_number": r.track_number, + "status_code": r.status_code, + "client_name": r.client_name, + "client_phone": r.client_phone, + "topic_code": r.topic_code, + "created_at": r.created_at.isoformat() if r.created_at else None, + "updated_at": r.updated_at.isoformat() if r.updated_at else None, + } + for r in rows + ], + "total": total, + } + + +@router.post("", status_code=201) +def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): + track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}" + row = Request( + track_number=track, + client_name=payload.client_name, + client_phone=payload.client_phone, + topic_code=payload.topic_code, + status_code=payload.status_code, + description=payload.description, + extra_fields=payload.extra_fields, + assigned_lawyer_id=payload.assigned_lawyer_id, + total_attachments_bytes=payload.total_attachments_bytes, + ) + try: + db.add(row) + db.commit() + db.refresh(row) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") + return {"id": str(row.id), "track_number": row.track_number} + + +@router.patch("/{request_id}") +def update_request( + request_id: str, + payload: RequestAdminPatch, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER")), +): + row = db.query(Request).filter(Request.id == request_id).first() + if not row: + raise HTTPException(status_code=404, detail="Заявка не найдена") + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(row, key, value) + try: + db.add(row) + db.commit() + db.refresh(row) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") + return {"status": "обновлено", "id": str(row.id), "track_number": row.track_number} + + +@router.delete("/{request_id}") +def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): + row = db.query(Request).filter(Request.id == request_id).first() + if not row: + raise HTTPException(status_code=404, detail="Заявка не найдена") + db.delete(row) + db.commit() + return {"status": "удалено"} + +@router.get("/{request_id}") +def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))): + req = db.query(Request).filter(Request.id == request_id).first() + if not req: + raise HTTPException(status_code=404, detail="Заявка не найдена") + return { + "id": str(req.id), + "track_number": req.track_number, + "client_name": req.client_name, + "client_phone": req.client_phone, + "topic_code": req.topic_code, + "status_code": req.status_code, + "description": req.description, + "extra_fields": req.extra_fields, + "assigned_lawyer_id": req.assigned_lawyer_id, + "total_attachments_bytes": req.total_attachments_bytes, + "created_at": req.created_at.isoformat() if req.created_at else None, + "updated_at": req.updated_at.isoformat() if req.updated_at else None, + } diff --git a/app/api/admin/router.py b/app/api/admin/router.py new file mode 100644 index 0000000..5a78dcb --- /dev/null +++ b/app/api/admin/router.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter +from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics + +router = APIRouter() +router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"]) +router.include_router(requests.router, prefix="/requests", tags=["AdminRequests"]) +router.include_router(quotes.router, prefix="/quotes", tags=["AdminQuotes"]) +router.include_router(meta.router, prefix="/meta", tags=["AdminMeta"]) +router.include_router(config.router, prefix="/config", tags=["AdminConfig"]) +router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"]) +router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"]) diff --git a/app/api/admin/uploads.py b/app/api/admin/uploads.py new file mode 100644 index 0000000..28c9b9a --- /dev/null +++ b/app/api/admin/uploads.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter, Depends +from app.core.deps import require_role + +router = APIRouter() + +@router.post("/init") +def upload_init(admin=Depends(require_role("ADMIN","LAWYER"))): + return {"method": "PRESIGNED_PUT", "presigned_url": "https://s3.local/..."} + +@router.post("/complete") +def upload_complete(admin=Depends(require_role("ADMIN","LAWYER"))): + return {"status": "ok"} diff --git a/app/api/public/otp.py b/app/api/public/otp.py new file mode 100644 index 0000000..f51139a --- /dev/null +++ b/app/api/public/otp.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Response +from datetime import timedelta +from app.schemas.public import OtpSend, OtpVerify +from app.core.config import settings +from app.core.security import create_jwt + +router = APIRouter() + +@router.post("/send") +def send_otp(payload: OtpSend): + return {"status": "sent"} + +@router.post("/verify") +def verify_otp(payload: OtpVerify, response: Response): + token = create_jwt({"sub": payload.track_number or "unknown", "purpose": payload.purpose}, + settings.PUBLIC_JWT_SECRET, timedelta(days=settings.PUBLIC_JWT_TTL_DAYS)) + response.set_cookie( + key=settings.PUBLIC_COOKIE_NAME, + value=token, + httponly=True, + secure=False, + samesite="lax", + max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600, + ) + return {"status": "verified"} diff --git a/app/api/public/quotes.py b/app/api/public/quotes.py new file mode 100644 index 0000000..54f7092 --- /dev/null +++ b/app/api/public/quotes.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from sqlalchemy import func +from app.db.session import get_db +from app.models.quote import Quote + +router = APIRouter() + +@router.get("") +def get_quotes(limit: int = Query(20, ge=1, le=200), order: str = Query("random"), db: Session = Depends(get_db)): + q = db.query(Quote).filter(Quote.is_active == True) + if order == "sort_order": + q = q.order_by(Quote.sort_order.asc(), Quote.created_at.desc()) + else: + q = q.order_by(func.random()) + rows = q.limit(limit).all() + return [{"id": str(r.id), "text": r.text, "author": r.author, "source": r.source} for r in rows] diff --git a/app/api/public/requests.py b/app/api/public/requests.py new file mode 100644 index 0000000..a07d5f6 --- /dev/null +++ b/app/api/public/requests.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from uuid import uuid4 +from app.db.session import get_db +from app.schemas.public import PublicRequestCreate, PublicRequestCreated +from app.models.request import Request + +router = APIRouter() + +@router.post("", response_model=PublicRequestCreated, status_code=201) +def create_request(payload: PublicRequestCreate, db: Session = Depends(get_db)): + track = f"TRK-{uuid4().hex[:10].upper()}" + r = Request(track_number=track, client_name=payload.client_name, client_phone=payload.client_phone, + topic_code=payload.topic_code, description=payload.description, extra_fields=payload.extra_fields) + db.add(r); db.commit(); db.refresh(r) + return PublicRequestCreated(request_id=r.id, track_number=r.track_number, otp_required=True) diff --git a/app/api/public/router.py b/app/api/public/router.py new file mode 100644 index 0000000..846e6d0 --- /dev/null +++ b/app/api/public/router.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter +from app.api.public import requests, otp, quotes, uploads + +router = APIRouter() +router.include_router(requests.router, prefix="/requests", tags=["Public"]) +router.include_router(otp.router, prefix="/otp", tags=["Public"]) +router.include_router(quotes.router, prefix="/quotes", tags=["Public"]) +router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"]) diff --git a/app/api/public/uploads.py b/app/api/public/uploads.py new file mode 100644 index 0000000..991c309 --- /dev/null +++ b/app/api/public/uploads.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter +router = APIRouter() + +@router.post("/init") +def upload_init(): + return {"method": "PRESIGNED_PUT", "presigned_url": "https://s3.local/..."} + +@router.post("/complete") +def upload_complete(): + return {"status": "ok"} diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..0e16a4e --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,40 @@ +from pydantic_settings import BaseSettings +from typing import List + +class Settings(BaseSettings): + APP_ENV: str = "local" + APP_NAME: str = "legal-case-tracker" + + PUBLIC_JWT_TTL_DAYS: int = 7 + ADMIN_JWT_TTL_MINUTES: int = 240 + ADMIN_JWT_SECRET: str = "change_me_admin" + PUBLIC_JWT_SECRET: str = "change_me_public" + PUBLIC_COOKIE_NAME: str = "public_jwt" + + CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8081" + + DATABASE_URL: str + REDIS_URL: str + + S3_ENDPOINT: str + S3_ACCESS_KEY: str + S3_SECRET_KEY: str + S3_BUCKET: str + S3_REGION: str = "us-east-1" + S3_USE_SSL: bool = False + MAX_FILE_MB: int = 25 + MAX_CASE_MB: int = 350 + + TELEGRAM_BOT_TOKEN: str = "change_me" + TELEGRAM_CHAT_ID: str = "0" + SMS_PROVIDER: str = "dummy" + + @property + def cors_origins_list(self) -> List[str]: + return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()] + + class Config: + env_file = ".env" + case_sensitive = True + +settings = Settings() diff --git a/app/core/deps.py b/app/core/deps.py new file mode 100644 index 0000000..135a1aa --- /dev/null +++ b/app/core/deps.py @@ -0,0 +1,29 @@ +from fastapi import Depends, Cookie, HTTPException +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from app.core.config import settings +from app.core.security import decode_jwt + +bearer = HTTPBearer(auto_error=False) + +def get_current_admin(creds: HTTPAuthorizationCredentials = Depends(bearer)) -> dict: + if not creds: + raise HTTPException(status_code=401, detail="Отсутствует токен авторизации") + try: + return decode_jwt(creds.credentials, settings.ADMIN_JWT_SECRET) + except Exception: + raise HTTPException(status_code=401, detail="Некорректный токен") + +def require_role(*roles: str): + def _inner(admin: dict = Depends(get_current_admin)) -> dict: + if admin.get("role") not in roles: + raise HTTPException(status_code=403, detail="Недостаточно прав") + return admin + return _inner + +def get_public_session(public_jwt: str | None = Cookie(default=None, alias=settings.PUBLIC_COOKIE_NAME)) -> dict: + if not public_jwt: + raise HTTPException(status_code=401, detail="Отсутствует публичная сессия") + try: + return decode_jwt(public_jwt, settings.PUBLIC_JWT_SECRET) + except Exception: + raise HTTPException(status_code=401, detail="Некорректная публичная сессия") diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..a78ae4b --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,20 @@ +from datetime import datetime, timedelta, timezone +from jose import jwt +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + +def verify_password(password: str, password_hash: str) -> bool: + return pwd_context.verify(password, password_hash) + +def create_jwt(payload: dict, secret: str, expires_delta: timedelta) -> str: + now = datetime.now(timezone.utc) + data = payload.copy() + data.update({"iat": int(now.timestamp()), "exp": int((now + expires_delta).timestamp())}) + return jwt.encode(data, secret, algorithm="HS256") + +def decode_jwt(token: str, secret: str) -> dict: + return jwt.decode(token, secret, algorithms=["HS256"]) diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..982ec79 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,16 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +from app.core.config import settings + +engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + +class Base(DeclarativeBase): + pass + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..d0d258a --- /dev/null +++ b/app/main.py @@ -0,0 +1,30 @@ +from pathlib import Path +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from app.core.config import settings +from app.api.public.router import router as public_router +from app.api.admin.router import router as admin_router + +app = FastAPI(title=settings.APP_NAME, version="0.1.0") +BASE_DIR = Path(__file__).resolve().parent +LANDING_FILE = BASE_DIR / "web" / "landing.html" + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(public_router, prefix="/api/public") +app.include_router(admin_router, prefix="/api/admin") + +@app.get("/", include_in_schema=False) +def landing(): + return FileResponse(LANDING_FILE) + +@app.get("/health") +def health(): + return {"status": "ok"} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/admin_user.py b/app/models/admin_user.py new file mode 100644 index 0000000..2bf50f1 --- /dev/null +++ b/app/models/admin_user.py @@ -0,0 +1,12 @@ +from sqlalchemy import String, Boolean +from sqlalchemy.orm import Mapped, mapped_column +from app.db.session import Base +from app.models.common import UUIDMixin, TimestampMixin + +class AdminUser(Base, UUIDMixin, TimestampMixin): + __tablename__ = "admin_users" + role: Mapped[str] = mapped_column(String(20), nullable=False) # ADMIN|LAWYER + name: Mapped[str] = mapped_column(String(200), nullable=False) + email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) diff --git a/app/models/attachment.py b/app/models/attachment.py new file mode 100644 index 0000000..b62a7af --- /dev/null +++ b/app/models/attachment.py @@ -0,0 +1,16 @@ +import uuid +from sqlalchemy import String, Integer, Boolean +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.db.session import Base +from app.models.common import UUIDMixin, TimestampMixin + +class Attachment(Base, UUIDMixin, TimestampMixin): + __tablename__ = "attachments" + request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True) + file_name: Mapped[str] = mapped_column(String(300), nullable=False) + mime_type: Mapped[str] = mapped_column(String(150), nullable=False) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + s3_key: Mapped[str] = mapped_column(String(500), nullable=False) + immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/app/models/audit_log.py b/app/models/audit_log.py new file mode 100644 index 0000000..9f1f280 --- /dev/null +++ b/app/models/audit_log.py @@ -0,0 +1,14 @@ +import uuid +from sqlalchemy import String, JSON +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.db.session import Base +from app.models.common import UUIDMixin, TimestampMixin + +class AuditLog(Base, UUIDMixin, TimestampMixin): + __tablename__ = "audit_log" + actor_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + entity: Mapped[str] = mapped_column(String(80), nullable=False) + entity_id: Mapped[str] = mapped_column(String(80), nullable=False) + action: Mapped[str] = mapped_column(String(30), nullable=False) + diff: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False) diff --git a/app/models/common.py b/app/models/common.py new file mode 100644 index 0000000..5eae76c --- /dev/null +++ b/app/models/common.py @@ -0,0 +1,15 @@ +import uuid +from datetime import datetime, timezone +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy import DateTime + +def utcnow(): + return datetime.now(timezone.utc) + +class UUIDMixin: + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/app/models/form_field.py b/app/models/form_field.py new file mode 100644 index 0000000..54c92ce --- /dev/null +++ b/app/models/form_field.py @@ -0,0 +1,14 @@ +from sqlalchemy import String, Boolean, Integer, JSON +from sqlalchemy.orm import Mapped, mapped_column +from app.db.session import Base +from app.models.common import UUIDMixin, TimestampMixin + +class FormField(Base, UUIDMixin, TimestampMixin): + __tablename__ = "form_fields" + key: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) + label: Mapped[str] = mapped_column(String(200), nullable=False) + type: Mapped[str] = mapped_column(String(30), nullable=False) + required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + options: Mapped[dict | None] = mapped_column(JSON, nullable=True) diff --git a/app/models/message.py b/app/models/message.py new file mode 100644 index 0000000..1b29d19 --- /dev/null +++ b/app/models/message.py @@ -0,0 +1,14 @@ +import uuid +from sqlalchemy import String, Text, Boolean +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.db.session import Base +from app.models.common import UUIDMixin, TimestampMixin + +class Message(Base, UUIDMixin, TimestampMixin): + __tablename__ = "messages" + request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + author_type: Mapped[str] = mapped_column(String(20), nullable=False) # CLIENT|LAWYER|SYSTEM + author_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/app/models/otp_session.py b/app/models/otp_session.py new file mode 100644 index 0000000..7b48290 --- /dev/null +++ b/app/models/otp_session.py @@ -0,0 +1,17 @@ +from sqlalchemy import String, Integer, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime, timezone, timedelta +from app.db.session import Base +from app.models.common import UUIDMixin, TimestampMixin + +def utcnow(): + return datetime.now(timezone.utc) + +class OtpSession(Base, UUIDMixin, TimestampMixin): + __tablename__ = "otp_sessions" + purpose: Mapped[str] = mapped_column(String(30), nullable=False) + track_number: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) + phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + code_hash: Mapped[str] = mapped_column(String(255), nullable=False) + attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: utcnow() + timedelta(minutes=10)) diff --git a/app/models/quote.py b/app/models/quote.py new file mode 100644 index 0000000..faef767 --- /dev/null +++ b/app/models/quote.py @@ -0,0 +1,12 @@ +from sqlalchemy import String, Boolean, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column +from app.db.session import Base +from app.models.common import UUIDMixin, TimestampMixin + +class Quote(Base, UUIDMixin, TimestampMixin): + __tablename__ = "quotes" + text: Mapped[str] = mapped_column(Text, nullable=False) + author: Mapped[str] = mapped_column(String(200), nullable=False) + source: Mapped[str | None] = mapped_column(String(400), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/app/models/request.py b/app/models/request.py new file mode 100644 index 0000000..145f7ea --- /dev/null +++ b/app/models/request.py @@ -0,0 +1,16 @@ +from sqlalchemy import String, Integer, Text, JSON +from sqlalchemy.orm import Mapped, mapped_column +from app.db.session import Base +from app.models.common import UUIDMixin, TimestampMixin + +class Request(Base, UUIDMixin, TimestampMixin): + __tablename__ = "requests" + track_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True) + client_name: Mapped[str] = mapped_column(String(200), nullable=False) + client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + status_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True, default="NEW") + description: Mapped[str | None] = mapped_column(Text, nullable=True) + extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False) + assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + total_attachments_bytes: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/app/models/status.py b/app/models/status.py new file mode 100644 index 0000000..eeba0a1 --- /dev/null +++ b/app/models/status.py @@ -0,0 +1,12 @@ +from sqlalchemy import String, Boolean, Integer +from sqlalchemy.orm import Mapped, mapped_column +from app.db.session import Base +from app.models.common import UUIDMixin, TimestampMixin + +class Status(Base, UUIDMixin, TimestampMixin): + __tablename__ = "statuses" + code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(200), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/app/models/status_history.py b/app/models/status_history.py new file mode 100644 index 0000000..94af80c --- /dev/null +++ b/app/models/status_history.py @@ -0,0 +1,14 @@ +import uuid +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.db.session import Base +from app.models.common import UUIDMixin, TimestampMixin + +class StatusHistory(Base, UUIDMixin, TimestampMixin): + __tablename__ = "status_history" + request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + from_status: Mapped[str | None] = mapped_column(String(50), nullable=True) + to_status: Mapped[str] = mapped_column(String(50), nullable=False) + changed_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + comment: Mapped[str | None] = mapped_column(String(400), nullable=True) diff --git a/app/models/topic.py b/app/models/topic.py new file mode 100644 index 0000000..c9e2f6c --- /dev/null +++ b/app/models/topic.py @@ -0,0 +1,11 @@ +from sqlalchemy import String, Boolean, Integer +from sqlalchemy.orm import Mapped, mapped_column +from app.db.session import Base +from app.models.common import UUIDMixin, TimestampMixin + +class Topic(Base, UUIDMixin, TimestampMixin): + __tablename__ = "topics" + code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(200), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/app/schemas/admin.py b/app/schemas/admin.py new file mode 100644 index 0000000..e27e231 --- /dev/null +++ b/app/schemas/admin.py @@ -0,0 +1,66 @@ +from pydantic import BaseModel, Field +from typing import Optional, Any + +class AdminLogin(BaseModel): + email: str + password: str + +class AdminToken(BaseModel): + access_token: str + token_type: str = "Bearer" + +class QuoteUpsert(BaseModel): + text: str + author: str + source: Optional[str] = None + is_active: bool = True + sort_order: int = 0 + + +class TopicUpsert(BaseModel): + code: str + name: str + enabled: bool = True + sort_order: int = 0 + + +class StatusUpsert(BaseModel): + code: str + name: str + enabled: bool = True + sort_order: int = 0 + is_terminal: bool = False + + +class FormFieldUpsert(BaseModel): + key: str + label: str + type: str + required: bool = False + enabled: bool = True + sort_order: int = 0 + options: Optional[dict] = None + + +class RequestAdminCreate(BaseModel): + track_number: Optional[str] = None + client_name: str + client_phone: str + topic_code: Optional[str] = None + status_code: str = "NEW" + description: Optional[str] = None + extra_fields: dict = Field(default_factory=dict) + assigned_lawyer_id: Optional[str] = None + total_attachments_bytes: int = 0 + + +class RequestAdminPatch(BaseModel): + track_number: Optional[str] = None + client_name: Optional[str] = None + client_phone: Optional[str] = None + topic_code: Optional[str] = None + status_code: Optional[str] = None + description: Optional[str] = None + extra_fields: Optional[dict] = None + assigned_lawyer_id: Optional[str] = None + total_attachments_bytes: Optional[int] = None diff --git a/app/schemas/public.py b/app/schemas/public.py new file mode 100644 index 0000000..53703e1 --- /dev/null +++ b/app/schemas/public.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any, List +from uuid import UUID + +class PublicRequestCreate(BaseModel): + client_name: str + client_phone: str + topic_code: Optional[str] = None + description: Optional[str] = None + extra_fields: Dict[str, Any] = Field(default_factory=dict) + attachment_ids: Optional[List[UUID]] = None + +class PublicRequestCreated(BaseModel): + request_id: UUID + track_number: str + otp_required: bool = True + +class OtpSend(BaseModel): + purpose: str + track_number: Optional[str] = None + client_phone: Optional[str] = None + +class OtpVerify(BaseModel): + purpose: str + track_number: Optional[str] = None + code: str diff --git a/app/schemas/universal.py b/app/schemas/universal.py new file mode 100644 index 0000000..33ed532 --- /dev/null +++ b/app/schemas/universal.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from typing import Any, List, Literal + +Op = Literal["=", "!=", ">", "<", ">=", "<=", "~"] +Dir = Literal["asc", "desc"] + +class FilterClause(BaseModel): + field: str + op: Op + value: Any + +class SortClause(BaseModel): + field: str + dir: Dir + +class Page(BaseModel): + limit: int = 50 + offset: int = 0 + +class UniversalQuery(BaseModel): + filters: List[FilterClause] = [] + sort: List[SortClause] = [] + page: Page = Page() diff --git a/app/services/universal_query.py b/app/services/universal_query.py new file mode 100644 index 0000000..ab26e23 --- /dev/null +++ b/app/services/universal_query.py @@ -0,0 +1,29 @@ +from sqlalchemy.orm import Query +from sqlalchemy import asc, desc +from app.schemas.universal import UniversalQuery + +def apply_universal_query(q: Query, model, uq: UniversalQuery) -> Query: + for f in uq.filters: + col = getattr(model, f.field, None) + if col is None: + continue + if f.op == "=": + q = q.filter(col == f.value) + elif f.op == "!=": + q = q.filter(col != f.value) + elif f.op == ">": + q = q.filter(col > f.value) + elif f.op == "<": + q = q.filter(col < f.value) + elif f.op == ">=": + q = q.filter(col >= f.value) + elif f.op == "<=": + q = q.filter(col <= f.value) + elif f.op == "~": + q = q.filter(col.ilike(f"%{f.value}%")) + for s in uq.sort: + col = getattr(model, s.field, None) + if col is None: + continue + q = q.order_by(asc(col) if s.dir == "asc" else desc(col)) + return q diff --git a/app/web/admin.html b/app/web/admin.html new file mode 100644 index 0000000..27de879 --- /dev/null +++ b/app/web/admin.html @@ -0,0 +1,675 @@ + + + + + + Административная панель • Правовой трекер + + + + + + +
+ + + + + + diff --git a/app/web/admin.jsx b/app/web/admin.jsx new file mode 100644 index 0000000..2a8d7cf --- /dev/null +++ b/app/web/admin.jsx @@ -0,0 +1,1846 @@ +(function () { + const { useCallback, useEffect, useMemo, useRef, useState } = React; + + const LS_TOKEN = "admin_access_token"; + const PAGE_SIZE = 50; + const DEFAULT_FORM_FIELD_TYPES = ["string", "text", "number", "boolean", "date"]; + const ALL_OPERATORS = ["=", "!=", ">", "<", ">=", "<=", "~"]; + const OPERATOR_LABELS = { + "=": "=", + "!=": "!=", + ">": ">", + "<": "<", + ">=": ">=", + "<=": "<=", + "~": "~", + }; + + const ROLE_LABELS = { + ADMIN: "Администратор", + LAWYER: "Юрист", + }; + + const STATUS_LABELS = { + NEW: "Новая", + IN_PROGRESS: "В работе", + WAITING_CLIENT: "Ожидание клиента", + WAITING_COURT: "Ожидание суда", + RESOLVED: "Решена", + CLOSED: "Закрыта", + REJECTED: "Отклонена", + }; + + const TABLE_SERVER_CONFIG = { + requests: { + endpoint: "/api/admin/requests/query", + sort: [{ field: "created_at", dir: "desc" }], + }, + quotes: { + endpoint: "/api/admin/quotes/query", + sort: [{ field: "sort_order", dir: "asc" }], + }, + topics: { + endpoint: "/api/admin/config/topics/query", + sort: [{ field: "sort_order", dir: "asc" }], + }, + statuses: { + endpoint: "/api/admin/config/statuses/query", + sort: [{ field: "sort_order", dir: "asc" }], + }, + formFields: { + endpoint: "/api/admin/config/form-fields/query", + sort: [{ field: "sort_order", dir: "asc" }], + }, + }; + + function createTableState() { + return { + filters: [], + sort: null, + offset: 0, + total: 0, + showAll: false, + rows: [], + }; + } + + function decodeJwtPayload(token) { + try { + const payload = token.split(".")[1] || ""; + const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); + const json = decodeURIComponent( + atob(base64) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join("") + ); + return JSON.parse(json); + } catch (_) { + return null; + } + } + + function sortByName(items) { + return [...items].sort((a, b) => String(a.name || a.code || "").localeCompare(String(b.name || b.code || ""), "ru")); + } + + function roleLabel(role) { + return ROLE_LABELS[role] || role || "-"; + } + + function statusLabel(code) { + return STATUS_LABELS[code] || code || "-"; + } + + function boolLabel(value) { + return value ? "Да" : "Нет"; + } + + function boolFilterLabel(value) { + return value ? "True" : "False"; + } + + function fmtDate(value) { + if (!value) return "-"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU"); + } + + function buildUniversalQuery(filters, sort, limit, offset) { + return { + filters: filters || [], + sort: sort || [], + page: { limit: limit ?? PAGE_SIZE, offset: offset ?? 0 }, + }; + } + + function canAccessSection(role, section) { + if (section === "quotes" || section === "config") return role === "ADMIN"; + return true; + } + + function translateApiError(message) { + const direct = { + "Missing auth token": "Отсутствует токен авторизации", + "Missing bearer token": "Отсутствует токен авторизации", + "Invalid token": "Некорректный токен", + Forbidden: "Недостаточно прав", + "Invalid credentials": "Неверный логин или пароль", + "Request not found": "Заявка не найдена", + "Quote not found": "Цитата не найдена", + not_found: "Запись не найдена", + }; + if (direct[message]) return direct[message]; + if (String(message).startsWith("HTTP ")) return "Ошибка сервера (" + message + ")"; + return message; + } + + function getOperatorsForType(type) { + if (type === "number" || type === "date" || type === "datetime") return ["=", "!=", ">", "<", ">=", "<="]; + if (type === "boolean" || type === "reference" || type === "enum") return ["=", "!="]; + return [...ALL_OPERATORS]; + } + + function localizeRequestDetails(row) { + return { + ID: row.id || null, + "Номер заявки": row.track_number || null, + Клиент: row.client_name || null, + Телефон: row.client_phone || null, + "Тема (код)": row.topic_code || null, + Статус: statusLabel(row.status_code), + Описание: row.description || null, + "Дополнительные поля": row.extra_fields || {}, + "Назначенный юрист (ID)": row.assigned_lawyer_id || null, + "Общий размер вложений (байт)": row.total_attachments_bytes ?? 0, + Создано: fmtDate(row.created_at), + Обновлено: fmtDate(row.updated_at), + }; + } + + function localizeMeta(data) { + const fieldTypeMap = { + string: "строка", + text: "текст", + boolean: "булево", + number: "число", + date: "дата", + }; + return { + Сущность: data.entity, + Поля: (data.fields || []).map((field) => ({ + "Код поля": field.field_name, + Название: field.label, + Тип: fieldTypeMap[field.type] || field.type, + Обязательное: boolLabel(field.required), + "Только чтение": boolLabel(field.read_only), + "Редактируемые роли": (field.editable_roles || []).map(roleLabel), + })), + }; + } + + function StatusLine({ status }) { + return

{status?.message || ""}

; + } + + function Section({ active, children, id }) { + return ( +
+ {children} +
+ ); + } + + function DataTable({ headers, rows, emptyColspan, renderRow, onSort, sortClause }) { + return ( +
+ + + + {headers.map((header) => { + const h = typeof header === "string" ? { key: header, label: header } : header; + const sortable = Boolean(h.sortable && h.field && onSort); + const active = Boolean(sortable && sortClause && sortClause.field === h.field); + const direction = active ? sortClause.dir : ""; + return ( + + ); + })} + + + + {rows.length ? ( + rows.map((row, index) => renderRow(row, index)) + ) : ( + + + + )} + +
onSort(h.field) : undefined} + title={sortable ? "Нажмите для сортировки" : undefined} + > + + {h.label} + {sortable ? {direction === "desc" ? "↓" : "↑"} : null} + +
Нет данных
+
+ ); + } + + function TablePager({ tableState, onPrev, onNext, onLoadAll }) { + return ( +
+
+ {tableState.showAll + ? "Всего: " + tableState.total + " • показаны все записи" + : "Всего: " + tableState.total + " • смещение: " + tableState.offset} +
+
+ + + +
+
+ ); + } + + function FilterToolbar({ filters, onOpen, onRemove, onEdit, getChipLabel }) { + return ( +
+
+ {filters.length ? ( + filters.map((filter, index) => ( +
onEdit(index)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onEdit(index); + } + }} + title="Редактировать фильтр" + > + {getChipLabel(filter)} + +
+ )) + ) : ( + Фильтры не заданы + )} +
+
+ +
+
+ ); + } + + function Overlay({ open, onClose, children, id }) { + return ( +
+ {children} +
+ ); + } + + function IconButton({ icon, tooltip, onClick, tone }) { + return ( + + ); + } + + function LoginScreen({ onSubmit, status }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const submit = (event) => { + event.preventDefault(); + onSubmit(email, password); + }; + + return ( +
+
+

Вход в админ-панель

+

Используйте учетную запись администратора или юриста.

+
+
+ + setEmail(event.target.value)} + /> +
+
+ + setPassword(event.target.value)} + /> +
+ + + +
+
+ ); + } + + function FilterModal({ + open, + tableLabel, + fields, + draft, + status, + onClose, + onFieldChange, + onOpChange, + onValueChange, + onSubmit, + onClear, + getOperators, + getFieldOptions, + }) { + if (!open) return null; + + const selectedField = fields.find((field) => field.field === draft.field) || fields[0] || null; + const operators = getOperators(selectedField?.type || "text"); + const options = selectedField ? getFieldOptions(selectedField) : []; + + return ( + event.target.id === "filter-overlay" && onClose()}> +
event.stopPropagation()}> +
+
+

Фильтр таблицы

+

+ {tableLabel + ? (draft.editIndex !== null ? "Редактирование фильтра • " : "Новый фильтр • ") + "Таблица: " + tableLabel + : "Выберите поле, оператор и значение."} +

+
+ +
+
+
+ + +
+
+ + +
+
+ + {!selectedField || selectedField.type === "text" ? ( + + ) : selectedField.type === "number" ? ( + + ) : selectedField.type === "date" ? ( + + ) : selectedField.type === "boolean" ? ( + + ) : selectedField.type === "reference" || selectedField.type === "enum" ? ( + + ) : ( + + )} +
+
+ + + +
+ + +
+
+ ); + } + + function QuoteModal({ open, editing, form, status, onClose, onChange, onSubmit }) { + if (!open) return null; + return ( + event.target.id === "quote-overlay" && onClose()}> +
event.stopPropagation()}> +
+
+

{editing ? "Редактирование цитаты" : "Новая цитата"}

+

+ Создание и редактирование цитат. +

+
+ +
+
+
+ + onChange("author", event.target.value)} /> +
+
+ + +
+
+ + +
+
+ +

+
+
+
+ + + + + diff --git a/app/workers/celery_app.py b/app/workers/celery_app.py new file mode 100644 index 0000000..7dd71f2 --- /dev/null +++ b/app/workers/celery_app.py @@ -0,0 +1,12 @@ +from celery import Celery +from app.core.config import settings + +celery_app = Celery("legal_case_tracker", broker=settings.REDIS_URL, backend=settings.REDIS_URL) + +celery_app.conf.beat_schedule = { + "sla_check": {"task": "app.workers.tasks.sla.sla_check", "schedule": 300.0}, + "auto_assign_unclaimed": {"task": "app.workers.tasks.assign.auto_assign_unclaimed", "schedule": 3600.0}, + "cleanup_expired_otps": {"task": "app.workers.tasks.security.cleanup_expired_otps", "schedule": 3600.0}, + "cleanup_stale_uploads": {"task": "app.workers.tasks.uploads.cleanup_stale_uploads", "schedule": 86400.0}, +} +celery_app.conf.timezone = "Europe/Moscow" diff --git a/app/workers/tasks/assign.py b/app/workers/tasks/assign.py new file mode 100644 index 0000000..8213bf7 --- /dev/null +++ b/app/workers/tasks/assign.py @@ -0,0 +1,5 @@ +from app.workers.celery_app import celery_app + +@celery_app.task(name='app.workers.tasks.assign.auto_assign_unclaimed') +def auto_assign_unclaimed(): + return 'ok' diff --git a/app/workers/tasks/security.py b/app/workers/tasks/security.py new file mode 100644 index 0000000..12a4413 --- /dev/null +++ b/app/workers/tasks/security.py @@ -0,0 +1,5 @@ +from app.workers.celery_app import celery_app + +@celery_app.task(name='app.workers.tasks.security.cleanup_expired_otps') +def cleanup_expired_otps(): + return 'ok' diff --git a/app/workers/tasks/sla.py b/app/workers/tasks/sla.py new file mode 100644 index 0000000..4424cac --- /dev/null +++ b/app/workers/tasks/sla.py @@ -0,0 +1,5 @@ +from app.workers.celery_app import celery_app + +@celery_app.task(name='app.workers.tasks.sla.sla_check') +def sla_check(): + return 'ok' diff --git a/app/workers/tasks/uploads.py b/app/workers/tasks/uploads.py new file mode 100644 index 0000000..4c2202e --- /dev/null +++ b/app/workers/tasks/uploads.py @@ -0,0 +1,5 @@ +from app.workers.celery_app import celery_app + +@celery_app.task(name='app.workers.tasks.uploads.cleanup_stale_uploads') +def cleanup_stale_uploads(): + return 'ok' diff --git a/celerybeat-schedule b/celerybeat-schedule new file mode 100644 index 0000000000000000000000000000000000000000..e207585216c1c9dd2048a448a84e87027bc366a4 GIT binary patch literal 16384 zcmeI3F>4e-6vyY{Y3`DXM3NKHpf=(eWfIiX1|&r|VxfY;!ez2I$u76)uSOY zNJzVZF}9r9*Tzno10lby!6Ont0!RP}AOR$R1dsp{Kmter2_OL^@IMl`oF$FcfzikPn7BI?gj*$@H!t!VW`2{zt5O7SJ#y6(ni_De!b*HqOFI1AI*5t0SmN#h`54WU)P z%LAS)(K*hfRs#=Qk2lhQPeF1^dR^DNf*NCSOc$f12`N&^L{rero$hndQiDk@{ij^3 z?e4n1+t>TEr+RjLwDRUFd^4B&f%HR2qIZEyCS?KvRcSd2xgX9)(0vTuY14)AvXh3f z?=}OjU+DXg>P`yvf*CcfPSjwwD3PrOLXVAVLUYsdaopW;Zop!@Ep@naFZuJ>Pk6(LWGvbt?{upG!QC|R z<~zbg;O|ahpQWa`(tVGmYd7NYDP5JT issue public JWT cookie (7 days) + +## Anti-abuse +- Rate limit (Redis) +- Cooldown between sends +- Lock after N failed attempts \ No newline at end of file diff --git a/context/03_admin_panel_service.md b/context/03_admin_panel_service.md new file mode 100644 index 0000000..b9b97c4 --- /dev/null +++ b/context/03_admin_panel_service.md @@ -0,0 +1,17 @@ +# Admin Panel Service Context + +## Roles +- ADMIN: full CRUD + config + SLA + quotes +- LAWYER: work with assigned requests only + +## Core Features +- Universal table with filters (= != > < >= <= ~) +- Universal record modal (meta-driven) +- Manual editing of any table +- AuditLog for any CREATE/UPDATE/DELETE + +## Status Logic +- On any status change: + - All previous messages immutable + - All previous attachments immutable + - Add status_history record \ No newline at end of file diff --git a/context/04_files_service.md b/context/04_files_service.md new file mode 100644 index 0000000..5ccb62b --- /dev/null +++ b/context/04_files_service.md @@ -0,0 +1,12 @@ +# File Storage Service Context + +## Storage +- Self-hosted S3 (MinIO) +- Presigned PUT or multipart upload +- Store metadata in attachments table + +## Rules +- Max 25MB per file +- Max 350MB per request +- Immutable after status change +- Download via presigned GET or proxy endpoint \ No newline at end of file diff --git a/context/05_sla_auto_assign_service.md b/context/05_sla_auto_assign_service.md new file mode 100644 index 0000000..a9e3544 --- /dev/null +++ b/context/05_sla_auto_assign_service.md @@ -0,0 +1,23 @@ +# SLA & Auto Assign Service Context + +## Celery Queues +- maintenance +- notifications +- uploads + +## Periodic Tasks +- sla_check (every 5 min) +- auto_assign_unclaimed (every 60 min) +- cleanup_expired_otps (every 60 min) +- cleanup_stale_uploads (daily) + +## Auto Assign Logic +- If request unclaimed for 24h +- Match by topic +- Assign to lawyer with lowest active load + +## SLA Metrics +- First response time +- Time in status +- Overdue detection +- Telegram notification to group chat \ No newline at end of file diff --git a/context/06_quotes_service.md b/context/06_quotes_service.md new file mode 100644 index 0000000..8a1dea6 --- /dev/null +++ b/context/06_quotes_service.md @@ -0,0 +1,20 @@ +# Quotes Service Context + +## Purpose +Provide quotes for landing page carousel. + +## Storage +Table: quotes +Fields: +- text +- author +- source +- is_active +- sort_order + +## Public API +GET /api/public/quotes?limit=&order=random|sort_order + +## Admin +- Full CRUD (ADMIN only) +- Meta-driven form \ No newline at end of file diff --git a/context/07_universal_query_engine.md b/context/07_universal_query_engine.md new file mode 100644 index 0000000..76a6bad --- /dev/null +++ b/context/07_universal_query_engine.md @@ -0,0 +1,20 @@ +# Universal Query Engine Context + +## Purpose +Provide filtering/sorting/pagination for all admin tables. + +## Operators += != > < >= <= ~ (ilike) + +## Schema +UniversalQuery: +- filters[] +- sort[] +- page{limit,offset} + +## Used In +- requests +- quotes +- topics +- statuses +- form_fields \ No newline at end of file diff --git a/context/08_security_model.md b/context/08_security_model.md new file mode 100644 index 0000000..2413428 --- /dev/null +++ b/context/08_security_model.md @@ -0,0 +1,16 @@ +# Security Model Context + +## Public +- OTP verification required +- JWT in httpOnly cookie (7 days) +- Rate limiting +- Protection from brute force + +## Admin +- JWT bearer +- RBAC +- Audit log required + +## Data Protection +- Immutable after status change +- All actions logged \ No newline at end of file diff --git a/context/09_metrics_dashboard.md b/context/09_metrics_dashboard.md new file mode 100644 index 0000000..5dfaad5 --- /dev/null +++ b/context/09_metrics_dashboard.md @@ -0,0 +1,14 @@ +# Metrics & Dashboard Context + +## Admin Dashboard +- Highlight new requests +- Count by status +- SLA overdue +- Avg first response time +- Avg time in status + +## Data Sources +- requests +- status_history +- messages +- sla config \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f5e7cfe --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +services: + frontend: + build: + context: . + dockerfile: frontend/Dockerfile + container_name: law-frontend + depends_on: [backend] + ports: ["8081:80"] + + backend: + build: . + container_name: law-backend + env_file: .env + depends_on: [db, redis, minio] + ports: ["8002:8000"] + volumes: [".:/app"] + + worker: + build: . + container_name: law-worker + env_file: .env + depends_on: [db, redis, minio] + command: ["celery","-A","app.workers.celery_app:celery_app","worker","-Q","notifications,maintenance,uploads","-l","INFO"] + volumes: [".:/app"] + + beat: + build: . + container_name: law-beat + env_file: .env + depends_on: [redis] + command: ["celery","-A","app.workers.celery_app:celery_app","beat","-l","INFO"] + volumes: [".:/app"] + + db: + image: postgres:16 + container_name: law-db + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: legal + ports: ["5432:5432"] + volumes: ["pgdata:/var/lib/postgresql/data"] + + redis: + image: redis:7 + container_name: law-redis + ports: ["6379:6379"] + + minio: + image: minio/minio:latest + container_name: law-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: ["9000:9000", "9001:9001"] + volumes: ["miniodata:/data"] + +volumes: + pgdata: + miniodata: diff --git a/docs/architecture_fastapi_celery.md b/docs/architecture_fastapi_celery.md new file mode 100644 index 0000000..7f78824 --- /dev/null +++ b/docs/architecture_fastapi_celery.md @@ -0,0 +1,18 @@ +# Architecture (FastAPI + Celery/Beat) +- FastAPI API (public/admin) +- PostgreSQL +- Redis (rate-limit + Celery broker/backend) +- MinIO (self-hosted S3) +- Celery worker + beat (SLA, auto-assign, cleanup) +- Integrations: SMS (OTP), Telegram (notifications) + +## FastAPI module layout +app/ + core/ (config, security, deps) + db/ (engine/session) + models/ (SQLAlchemy entities) + schemas/ (Pydantic schemas) + services/ (business logic: otp, uploads, immutable, universal query) + api/public/ (landing endpoints) + api/admin/ (admin endpoints) + workers/ (celery app + tasks) diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..36ce651 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,41 @@ +openapi: 3.0.3 +info: + title: Legal Case Tracker API (FastAPI) + version: 1.3.0 +servers: + - url: http://localhost:8000 +components: + securitySchemes: + AdminBearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + PublicCookieAuth: + type: apiKey + in: cookie + name: public_jwt +paths: + /health: + get: + responses: { "200": { "description": "OK" } } + /api/public/quotes: + get: + responses: { "200": { "description": "OK" } } + /api/public/otp/send: + post: + responses: { "200": { "description": "OK" }, "429": { "description": "Too Many Requests" } } + /api/public/otp/verify: + post: + responses: { "200": { "description": "OK" }, "400": { "description": "Invalid code" } } + /api/admin/auth/login: + post: + responses: { "200": { "description": "OK" }, "401": { "description": "Unauthorized" } } + /api/admin/meta/{entity}: + get: + security: [{ AdminBearerAuth: [] }] + parameters: + - in: path + name: entity + required: true + schema: { type: string } + responses: { "200": { "description": "OK" } } diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..26cf800 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.27-alpine +COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf +COPY app/web/ /usr/share/nginx/html/ +RUN cp /usr/share/nginx/html/landing.html /usr/share/nginx/html/index.html diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..df91ede --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location = /admin { + return 302 /admin.html; + } + + location ~* \.jsx$ { + default_type application/javascript; + try_files $uri =404; + } + + location / { + try_files $uri /index.html; + } + + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + 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 /health { + proxy_pass http://backend:8000/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a89d374 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +pydantic==2.10.3 +pydantic-settings==2.6.1 +SQLAlchemy==2.0.36 +alembic==1.14.0 +psycopg[binary]==3.2.3 +python-jose==3.3.0 +passlib[bcrypt]==1.7.4 +redis==5.2.0 +celery==5.4.0 +boto3==1.35.70 +httpx==0.27.2 +python-multipart==0.0.12 diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..5cd624f --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,101 @@ +import os +import subprocess +import unittest +from pathlib import Path + +import psycopg +from sqlalchemy import create_engine, inspect, text +from sqlalchemy.engine import make_url + + +class MigrationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + db_url_raw = os.getenv("DATABASE_URL", "") + if not db_url_raw.startswith("postgresql"): + raise unittest.SkipTest("Migration test requires PostgreSQL DATABASE_URL") + + cls.project_root = Path(__file__).resolve().parents[1] + cls.base_url = make_url(db_url_raw) + cls.test_db_name = f"{cls.base_url.database}_migration_test" + cls.test_url = cls.base_url.set(database=cls.test_db_name) + cls.admin_url = cls.base_url.set(database="postgres") + + cls._drop_create_database() + cls._run_alembic_upgrade() + + cls.engine = create_engine(cls.test_url) + cls.inspector = inspect(cls.engine) + + @classmethod + def tearDownClass(cls): + if hasattr(cls, "engine"): + cls.engine.dispose() + if hasattr(cls, "admin_url") and hasattr(cls, "test_db_name"): + cls._drop_database() + + @classmethod + def _to_psycopg_dsn(cls, url): + return url.render_as_string(hide_password=False).replace("+psycopg", "") + + @classmethod + def _drop_create_database(cls): + dsn = cls._to_psycopg_dsn(cls.admin_url) + with psycopg.connect(dsn, autocommit=True) as conn: + conn.execute( + "SELECT pg_terminate_backend(pid) " + "FROM pg_stat_activity " + "WHERE datname = %s AND pid <> pg_backend_pid()", + (cls.test_db_name,), + ) + conn.execute(f'DROP DATABASE IF EXISTS "{cls.test_db_name}"') + conn.execute(f'CREATE DATABASE "{cls.test_db_name}"') + + @classmethod + def _drop_database(cls): + dsn = cls._to_psycopg_dsn(cls.admin_url) + with psycopg.connect(dsn, autocommit=True) as conn: + conn.execute( + "SELECT pg_terminate_backend(pid) " + "FROM pg_stat_activity " + "WHERE datname = %s AND pid <> pg_backend_pid()", + (cls.test_db_name,), + ) + conn.execute(f'DROP DATABASE IF EXISTS "{cls.test_db_name}"') + + @classmethod + def _run_alembic_upgrade(cls): + env = os.environ.copy() + env["DATABASE_URL"] = cls.test_url.render_as_string(hide_password=False) + env["PYTHONPATH"] = str(cls.project_root) + subprocess.run( + ["alembic", "upgrade", "head"], + cwd=cls.project_root, + env=env, + check=True, + capture_output=True, + text=True, + ) + + def test_upgrade_head_creates_expected_tables(self): + expected = { + "admin_users", + "topics", + "statuses", + "form_fields", + "requests", + "messages", + "attachments", + "status_history", + "audit_log", + "otp_sessions", + "quotes", + "alembic_version", + } + tables = set(self.inspector.get_table_names()) + self.assertTrue(expected.issubset(tables), f"Missing tables: {expected - tables}") + + def test_alembic_version_is_set(self): + with self.engine.connect() as conn: + version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one() + self.assertEqual(version, "0001_init") diff --git a/tests/test_public_requests.py b/tests/test_public_requests.py new file mode 100644 index 0000000..d86cf5a --- /dev/null +++ b/tests/test_public_requests.py @@ -0,0 +1,87 @@ +import os +import unittest +from uuid import UUID + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, delete +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +# Ensure settings can be initialized in test environments +os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000") +os.environ.setdefault("S3_ACCESS_KEY", "test") +os.environ.setdefault("S3_SECRET_KEY", "test") +os.environ.setdefault("S3_BUCKET", "test") + +from app.main import app +from app.db.session import get_db +from app.models.request import Request + + +class PublicRequestCreateTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False) + Request.__table__.create(bind=cls.engine) + + @classmethod + def tearDownClass(cls): + Request.__table__.drop(bind=cls.engine) + cls.engine.dispose() + + def setUp(self): + with self.SessionLocal() as db: + db.execute(delete(Request)) + db.commit() + + def override_get_db(): + db = self.SessionLocal() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + self.client = TestClient(app) + + def tearDown(self): + self.client.close() + app.dependency_overrides.clear() + + def test_create_request_persists_in_database(self): + payload = { + "client_name": "ООО Ромашка", + "client_phone": "+79990000001", + "topic_code": "consulting", + "description": "Тестируем создание заявки", + "extra_fields": {"referral_name": "Партнер"}, + } + + response = self.client.post("/api/public/requests", json=payload) + + self.assertEqual(response.status_code, 201) + body = response.json() + + self.assertTrue(body["track_number"].startswith("TRK-")) + self.assertTrue(body["otp_required"]) + self.assertIsNotNone(body["request_id"]) + + request_id = UUID(body["request_id"]) + + with self.SessionLocal() as db: + created = db.get(Request, request_id) + self.assertIsNotNone(created) + self.assertEqual(created.client_name, payload["client_name"]) + self.assertEqual(created.client_phone, payload["client_phone"]) + self.assertEqual(created.topic_code, payload["topic_code"]) + self.assertEqual(created.description, payload["description"]) + self.assertEqual(created.extra_fields, payload["extra_fields"]) + self.assertEqual(created.status_code, "NEW") + self.assertEqual(created.track_number, body["track_number"])