mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
first commit
This commit is contained in:
commit
112ab43b34
71 changed files with 5183 additions and 0 deletions
10
.idea/.gitignore
vendored
Normal file
10
.idea/.gitignore
vendored
Normal file
|
|
@ -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/
|
||||||
8
.idea/Law.iml
Normal file
8
.idea/Law.iml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.9" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
8
Dockerfile
Normal file
8
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
8
Makefile
Normal file
8
Makefile
Normal file
|
|
@ -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
|
||||||
17
README.md
Normal file
17
README.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
36
alembic.ini
Normal file
36
alembic.ini
Normal file
|
|
@ -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
|
||||||
44
alembic/env.py
Normal file
44
alembic/env.py
Normal file
|
|
@ -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()
|
||||||
179
alembic/versions/0001_init.py
Normal file
179
alembic/versions/0001_init.py
Normal file
|
|
@ -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")
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
19
app/api/admin/auth.py
Normal file
19
app/api/admin/auth.py
Normal file
|
|
@ -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)
|
||||||
182
app/api/admin/config.py
Normal file
182
app/api/admin/config.py
Normal file
|
|
@ -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": "удалено"}
|
||||||
18
app/api/admin/meta.py
Normal file
18
app/api/admin/meta.py
Normal file
|
|
@ -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, [])}
|
||||||
20
app/api/admin/metrics.py
Normal file
20
app/api/admin/metrics.py
Normal file
|
|
@ -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": {},
|
||||||
|
}
|
||||||
55
app/api/admin/quotes.py
Normal file
55
app/api/admin/quotes.py
Normal file
|
|
@ -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": "удалено"}
|
||||||
110
app/api/admin/requests.py
Normal file
110
app/api/admin/requests.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
11
app/api/admin/router.py
Normal file
11
app/api/admin/router.py
Normal file
|
|
@ -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"])
|
||||||
12
app/api/admin/uploads.py
Normal file
12
app/api/admin/uploads.py
Normal file
|
|
@ -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"}
|
||||||
25
app/api/public/otp.py
Normal file
25
app/api/public/otp.py
Normal file
|
|
@ -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"}
|
||||||
17
app/api/public/quotes.py
Normal file
17
app/api/public/quotes.py
Normal file
|
|
@ -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]
|
||||||
16
app/api/public/requests.py
Normal file
16
app/api/public/requests.py
Normal file
|
|
@ -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)
|
||||||
8
app/api/public/router.py
Normal file
8
app/api/public/router.py
Normal file
|
|
@ -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"])
|
||||||
10
app/api/public/uploads.py
Normal file
10
app/api/public/uploads.py
Normal file
|
|
@ -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"}
|
||||||
40
app/core/config.py
Normal file
40
app/core/config.py
Normal file
|
|
@ -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()
|
||||||
29
app/core/deps.py
Normal file
29
app/core/deps.py
Normal file
|
|
@ -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="Некорректная публичная сессия")
|
||||||
20
app/core/security.py
Normal file
20
app/core/security.py
Normal file
|
|
@ -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"])
|
||||||
16
app/db/session.py
Normal file
16
app/db/session.py
Normal file
|
|
@ -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()
|
||||||
30
app/main.py
Normal file
30
app/main.py
Normal file
|
|
@ -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"}
|
||||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
12
app/models/admin_user.py
Normal file
12
app/models/admin_user.py
Normal file
|
|
@ -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)
|
||||||
16
app/models/attachment.py
Normal file
16
app/models/attachment.py
Normal file
|
|
@ -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)
|
||||||
14
app/models/audit_log.py
Normal file
14
app/models/audit_log.py
Normal file
|
|
@ -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)
|
||||||
15
app/models/common.py
Normal file
15
app/models/common.py
Normal file
|
|
@ -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)
|
||||||
14
app/models/form_field.py
Normal file
14
app/models/form_field.py
Normal file
|
|
@ -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)
|
||||||
14
app/models/message.py
Normal file
14
app/models/message.py
Normal file
|
|
@ -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)
|
||||||
17
app/models/otp_session.py
Normal file
17
app/models/otp_session.py
Normal file
|
|
@ -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))
|
||||||
12
app/models/quote.py
Normal file
12
app/models/quote.py
Normal file
|
|
@ -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)
|
||||||
16
app/models/request.py
Normal file
16
app/models/request.py
Normal file
|
|
@ -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)
|
||||||
12
app/models/status.py
Normal file
12
app/models/status.py
Normal file
|
|
@ -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)
|
||||||
14
app/models/status_history.py
Normal file
14
app/models/status_history.py
Normal file
|
|
@ -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)
|
||||||
11
app/models/topic.py
Normal file
11
app/models/topic.py
Normal file
|
|
@ -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)
|
||||||
66
app/schemas/admin.py
Normal file
66
app/schemas/admin.py
Normal file
|
|
@ -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
|
||||||
26
app/schemas/public.py
Normal file
26
app/schemas/public.py
Normal file
|
|
@ -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
|
||||||
23
app/schemas/universal.py
Normal file
23
app/schemas/universal.py
Normal file
|
|
@ -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()
|
||||||
29
app/services/universal_query.py
Normal file
29
app/services/universal_query.py
Normal file
|
|
@ -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
|
||||||
675
app/web/admin.html
Normal file
675
app/web/admin.html
Normal file
|
|
@ -0,0 +1,675 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Административная панель • Правовой трекер</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Prata&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1015;
|
||||||
|
--bg-soft: #111922;
|
||||||
|
--surface: #15202b;
|
||||||
|
--surface-2: #1d2a37;
|
||||||
|
--line: rgba(196, 210, 228, 0.2);
|
||||||
|
--text: #f3f7fc;
|
||||||
|
--muted: #9db0c5;
|
||||||
|
--brand: #d4a86a;
|
||||||
|
--brand-soft: rgba(212, 168, 106, 0.14);
|
||||||
|
--ok: #4dbe93;
|
||||||
|
--danger: #ff7f7f;
|
||||||
|
--radius: 16px;
|
||||||
|
--shadow: 0 24px 58px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: radial-gradient(circle at 12% 2%, #1a2532, var(--bg) 50%), var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Manrope", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.modal-open { overflow: hidden; }
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 272px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, rgba(19, 29, 39, 0.94), rgba(13, 20, 27, 0.94));
|
||||||
|
padding: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: #ebf2fb;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: #d4deec;
|
||||||
|
padding: 0.72rem 0.78rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button:hover {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button.active {
|
||||||
|
border-color: rgba(212, 168, 106, 0.45);
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: #fde5c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-box {
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-box b {
|
||||||
|
color: #f4f7fc;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(130deg, rgba(22, 33, 45, 0.94), rgba(15, 23, 31, 0.97));
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Prata", serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
border: 1px solid rgba(212, 168, 106, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: #fedfb1;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.32rem 0.55rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(120deg, #d9b57f, #c59048);
|
||||||
|
color: #1b2430;
|
||||||
|
padding: 0.64rem 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { filter: brightness(1.05); }
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #dbe6f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
border-color: rgba(255, 127, 127, 0.3);
|
||||||
|
background: rgba(255, 127, 127, 0.13);
|
||||||
|
color: #ffd3d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(160deg, rgba(20, 30, 40, 0.93), rgba(14, 22, 30, 0.96));
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section.active { display: block; }
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Prata", serif;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.83rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card b {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #f6dab0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chips {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid rgba(212, 168, 106, 0.3);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: #f9dfb5;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip button {
|
||||||
|
border: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fce3bd;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-action {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #9fb3c8;
|
||||||
|
font-size: 0.73rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #3c4d62;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.58rem 0.68rem;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 108px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 840px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.63rem 0.65rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 0.89rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
color: #9fb3c8;
|
||||||
|
font-size: 0.77rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-th {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-head {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-indicator {
|
||||||
|
color: #6d7f96;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-indicator.active {
|
||||||
|
color: #f1d3a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
td code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
color: #f7dfb8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: #d8e4f4;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
border-color: rgba(212, 168, 106, 0.42);
|
||||||
|
background: rgba(212, 168, 106, 0.16);
|
||||||
|
color: #fce0b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.danger:hover {
|
||||||
|
border-color: rgba(255, 127, 127, 0.45);
|
||||||
|
background: rgba(255, 127, 127, 0.16);
|
||||||
|
color: #ffd9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(100% + 7px);
|
||||||
|
transform: translate(-50%, 2px);
|
||||||
|
background: #081018;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: #dce6f5;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0.24rem 0.4rem;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover::after,
|
||||||
|
.icon-btn:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-top: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
min-height: 1.1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.87rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok { color: var(--ok); }
|
||||||
|
.status.error { color: var(--danger); }
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triple {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-tree {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-self: start;
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-title {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #8da2bc;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: #d7e4f5;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.52rem 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node:hover {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node.active {
|
||||||
|
border-color: rgba(212, 168, 106, 0.5);
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: #f7dfb8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.block h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block .table-wrap table {
|
||||||
|
min-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.7rem;
|
||||||
|
background: #0e151d;
|
||||||
|
color: #ccdef4;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 380px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 40;
|
||||||
|
background: rgba(6, 10, 14, 0.78);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay.open { display: flex; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(680px, 100%);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(160deg, rgba(21, 31, 42, 0.95), rgba(14, 21, 28, 0.98));
|
||||||
|
padding: 0.9rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Prata", serif;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #d7e4f5;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
background: rgba(7, 11, 15, 0.86);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(160deg, rgba(24, 36, 48, 0.95), rgba(15, 24, 32, 0.98));
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Prata", serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1160px) {
|
||||||
|
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.triple { grid-template-columns: 1fr; }
|
||||||
|
.config-layout { grid-template-columns: 1fr; }
|
||||||
|
.config-tree { position: static; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.layout { grid-template-columns: 1fr; }
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.cards,
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.filter-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.filter-action {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="admin-root"></div>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<script type="text/babel" data-presets="env,react" src="/admin.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1846
app/web/admin.jsx
Normal file
1846
app/web/admin.jsx
Normal file
File diff suppressed because it is too large
Load diff
833
app/web/landing.html
Normal file
833
app/web/landing.html
Normal file
|
|
@ -0,0 +1,833 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Аудиторы корпоративной безопасности</title>
|
||||||
|
<meta name="description" content="Юридический консалтинг и судебное сопровождение для сложных бизнес-ситуаций.">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Prata&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1217;
|
||||||
|
--bg-soft: #121a22;
|
||||||
|
--surface: #171f29;
|
||||||
|
--surface-2: #1f2a37;
|
||||||
|
--text: #f4f7fb;
|
||||||
|
--muted: #a8b2c2;
|
||||||
|
--accent: #d4a968;
|
||||||
|
--accent-soft: rgba(212, 169, 104, 0.15);
|
||||||
|
--line: rgba(207, 217, 231, 0.18);
|
||||||
|
--ok: #49b68e;
|
||||||
|
--danger: #ff7b7b;
|
||||||
|
--radius: 18px;
|
||||||
|
--shadow: 0 30px 70px rgba(0, 0, 0, 0.32);
|
||||||
|
--maxw: 1180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: radial-gradient(circle at 12% 0%, #1a2430 0, var(--bg) 48%), var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Manrope", sans-serif;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.modal-open { overflow: hidden; }
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(600px 320px at 90% 8%, rgba(212, 169, 104, 0.1), transparent 70%),
|
||||||
|
radial-gradient(600px 360px at 10% 76%, rgba(94, 147, 227, 0.1), transparent 72%);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
width: min(var(--maxw), calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background: rgba(13, 18, 23, 0.78);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-inner {
|
||||||
|
min-height: 76px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 800;
|
||||||
|
max-width: 390px;
|
||||||
|
color: #eef4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #d6deea;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.82rem 1.25rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { transform: translateY(-1px); }
|
||||||
|
.btn:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(120deg, #d8b27b, #c6914a);
|
||||||
|
color: #17212d;
|
||||||
|
box-shadow: 0 16px 28px rgba(198, 145, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
border-color: var(--line);
|
||||||
|
color: #dde6f2;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 5.2rem 0 3rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 0.8fr;
|
||||||
|
gap: 1.1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Prata", serif;
|
||||||
|
font-size: clamp(2.05rem, 5.6vw, 4.2rem);
|
||||||
|
line-height: 1.08;
|
||||||
|
max-width: 13ch;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
margin: 1.1rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.66;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
max-width: 66ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
margin-top: 1.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(160deg, rgba(35, 48, 63, 0.92), rgba(21, 29, 39, 0.95));
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1.3rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -40px;
|
||||||
|
top: -40px;
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(212, 169, 104, 0.2), transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel small {
|
||||||
|
display: block;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #90a2b7;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.06rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
margin-top: 0.95rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat b {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: #f7dbb1;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat span {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #a7b3c5;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
section { padding: 1.3rem 0 2.2rem; }
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: end;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Prata", serif;
|
||||||
|
font-size: clamp(1.65rem, 4vw, 2.7rem);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0.65rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid { display: grid; gap: 0.9rem; }
|
||||||
|
|
||||||
|
.practices { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(160deg, rgba(25, 34, 45, 0.88), rgba(19, 26, 34, 0.95));
|
||||||
|
padding: 1.05rem;
|
||||||
|
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.25);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
animation: rise 0.6s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:nth-child(2) { animation-delay: 0.06s; }
|
||||||
|
.card:nth-child(3) { animation-delay: 0.12s; }
|
||||||
|
.card:nth-child(4) { animation-delay: 0.18s; }
|
||||||
|
.card:nth-child(5) { animation-delay: 0.24s; }
|
||||||
|
.card:nth-child(6) { animation-delay: 0.3s; }
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 0.52rem;
|
||||||
|
font-size: 1.03rem;
|
||||||
|
color: #f1f5fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #aab5c4;
|
||||||
|
line-height: 1.57;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approach {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: linear-gradient(160deg, rgba(22, 30, 40, 0.88), rgba(16, 23, 30, 0.95));
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline .step {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 2.2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline .step:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.timeline .step::before {
|
||||||
|
content: attr(data-step);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 1.55rem;
|
||||||
|
height: 1.55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1b2634;
|
||||||
|
background: linear-gradient(130deg, #e3c08f, #c5914b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline h3 { margin: 0 0 0.35rem; font-size: 1rem; }
|
||||||
|
.timeline p { margin: 0; color: var(--muted); line-height: 1.55; }
|
||||||
|
|
||||||
|
.quote {
|
||||||
|
border: 1px solid #4b5b71;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(160deg, #1e2b3c, #1a2432);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote p {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 5.3rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #dbe6f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-meta {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
color: #98adc7;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expert {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(160deg, rgba(26, 36, 48, 0.92), rgba(20, 27, 36, 0.95));
|
||||||
|
padding: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expert strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expert p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 0.38rem 0.62rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(212, 169, 104, 0.3);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: #f6d7a8;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-band {
|
||||||
|
margin: 1.4rem 0 2.4rem;
|
||||||
|
border: 1px solid rgba(212, 169, 104, 0.35);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(120deg, rgba(212, 169, 104, 0.14), rgba(66, 99, 145, 0.18));
|
||||||
|
padding: 1.15rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-band p {
|
||||||
|
margin: 0;
|
||||||
|
color: #d8e2f2;
|
||||||
|
line-height: 1.52;
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
padding: 1.8rem 0;
|
||||||
|
color: #94a6bc;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(7, 10, 14, 0.72);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.open { display: flex; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(620px, 100%);
|
||||||
|
max-height: 92vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(160deg, #18222e, #121a23);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.28rem;
|
||||||
|
font-family: "Prata", serif;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head p {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.54;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #dce5f2;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #9fb0c6;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #3b4b5f;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: #ecf2fb;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.72rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 108px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-foot {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #9bafc8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok { color: var(--ok); }
|
||||||
|
.status.error { color: var(--danger); }
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.hero,
|
||||||
|
.approach,
|
||||||
|
.practices {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 740px) {
|
||||||
|
.topbar-inner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.72rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding-top: 3.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="wrap topbar-inner">
|
||||||
|
<div class="brand">Аудиторы корпоративной безопасности</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="#practices">Компетенции</a>
|
||||||
|
<a href="#approach">Подход</a>
|
||||||
|
<a href="#expert">Эксперт</a>
|
||||||
|
<a href="/admin" class="btn btn-ghost">Админ-панель</a>
|
||||||
|
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="wrap">
|
||||||
|
<section class="hero">
|
||||||
|
<div>
|
||||||
|
<h1>Решаем сложные юридические задачи в интересах вашего бизнеса.</h1>
|
||||||
|
<p>
|
||||||
|
Консалтинговая компания «Аудиторы корпоративной безопасности» освобождает ваше время для развития компании.
|
||||||
|
Мы разбираем любой бизнес-процесс и предлагаем решение, выгодное клиенту в текущем и стратегическом горизонте.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<button class="btn btn-primary" type="button" data-open-modal>Записаться на консультацию</button>
|
||||||
|
<a class="btn btn-ghost" href="#practices">Смотреть практики</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside class="panel">
|
||||||
|
<small>Первая консультация</small>
|
||||||
|
<strong>Анализ вашей ситуации проводит онлайн лично директор компании.</strong>
|
||||||
|
<p class="subtitle">После отправки заявки вы получите предложение по дате и времени онлайн-встречи.</p>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<b>28 лет</b>
|
||||||
|
<span>практики в профессии</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<b>10+ лет</b>
|
||||||
|
<span>стаж экспертов направлений</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<b>29 млрд ₽</b>
|
||||||
|
<span>объем восстановленных прав</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="practices">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>Ключевые практики</h2>
|
||||||
|
<p class="subtitle">Работаем в конфигурациях, где задача находится на стыке права, финансов, кадров и корпоративной безопасности.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid practices">
|
||||||
|
<article class="card">
|
||||||
|
<h3>Судебное сопровождение и арбитраж</h3>
|
||||||
|
<p>Налоговые и бюджетные споры, защита собственности и корпоративных прав, оспаривание сделок, сложные каскадные процессы.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>Банкротство и антикризис</h3>
|
||||||
|
<p>Представление интересов должника, кредитора и иных участников в делах о банкротстве юридических и физических лиц.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>Сделки и защита активов</h3>
|
||||||
|
<p>Сопровождение покупки и продажи активов, снижение правовых рисков, выстраивание безопасной структуры сделки.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>Кадры и корпоративная устойчивость</h3>
|
||||||
|
<p>Подбор и адаптация персонала, мотивация, командная динамика, выявление конфликтов интересов и конкурентных рисков.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>Финансовая и экспертная аналитика</h3>
|
||||||
|
<p>Оценка активов, экспертиза управленческих решений, формирование аргументированной позиции для переговоров и суда.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h3>PR и GR сопровождение</h3>
|
||||||
|
<p>Поддержка чувствительных кейсов в публичном и регуляторном контуре с учетом репутационных и правовых факторов.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="approach">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>Как мы работаем</h2>
|
||||||
|
<p class="subtitle">К нам обращаются, когда ситуация сложная, решение нужно быстро, а цена ошибки для бизнеса высока.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="approach">
|
||||||
|
<article class="timeline">
|
||||||
|
<div class="step" data-step="01">
|
||||||
|
<h3>Диагностика ситуации</h3>
|
||||||
|
<p>Декомпозируем кейс на правовые, финансовые и управленческие блоки и определяем критические риски.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step" data-step="02">
|
||||||
|
<h3>Стратегия защиты</h3>
|
||||||
|
<p>Формируем архитектуру действий: досудебное урегулирование, переговоры, процессуальная и судебная траектория.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step" data-step="03">
|
||||||
|
<h3>Реализация и контроль</h3>
|
||||||
|
<p>Сопровождаем исполнение решения, фиксируем сроки и контрольные точки, отчитываемся в понятном бизнес-формате.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="quote">
|
||||||
|
<small>Публичные цитаты</small>
|
||||||
|
<p id="quote-text">Загрузка данных...</p>
|
||||||
|
<div class="quote-meta" id="quote-meta"></div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="expert">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>Экспертный контур</h2>
|
||||||
|
<p class="subtitle">Генеральный директор ООО «Аудиторы корпоративной безопасности» — кандидат юридических наук, эксперт в сфере M&A, медиатор и третейский судья.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<article class="expert">
|
||||||
|
<strong>Направления деятельности: юридический консалтинг и судебное сопровождение</strong>
|
||||||
|
<p>
|
||||||
|
Защита права собственности и корпоративных прав, налоговые и бюджетные споры, сложные переговорные процессы,
|
||||||
|
оценка бизнес-рисков и активов, медиация и досудебное урегулирование конфликтов.
|
||||||
|
Преподавательская практика: ВШЭ, РАНХиГС, ЯрГУ им. Демидова и профильные программы МВА.
|
||||||
|
</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">Слияния и поглощения</span>
|
||||||
|
<span class="tag">Судебная стратегия</span>
|
||||||
|
<span class="tag">Корпоративная безопасность</span>
|
||||||
|
<span class="tag">Медиация</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta-band">
|
||||||
|
<p>
|
||||||
|
Если вы пришли на сайт по рекомендации, укажите имя рекомендателя при отправке заявки.
|
||||||
|
Это поможет быстрее подготовиться к консультации и учесть контекст вашей ситуации.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
ООО «Аудиторы корпоративной безопасности» • Юридический консалтинг и судебное сопровождение
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="request-modal" aria-hidden="true">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<h3 id="modal-title">Создание заявки</h3>
|
||||||
|
<p>Опишите задачу, и мы предложим дату и время первой онлайн-консультации.</p>
|
||||||
|
</div>
|
||||||
|
<button class="close" type="button" data-close-modal aria-label="Закрыть">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="request-form" class="form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">Имя</label>
|
||||||
|
<input id="name" name="name" type="text" required placeholder="Иван Иванов">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="phone">Телефон</label>
|
||||||
|
<input id="phone" name="phone" type="tel" required placeholder="+7 (900) 000-00-00">
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label for="description">Описание задачи</label>
|
||||||
|
<textarea id="description" name="description" placeholder="Кратко опишите ситуацию"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label for="referral">Кто вас порекомендовал</label>
|
||||||
|
<input id="referral" name="referral" type="text" placeholder="Имя рекомендателя">
|
||||||
|
</div>
|
||||||
|
<div class="form-foot field full">
|
||||||
|
<button class="btn btn-primary" type="submit">Отправить заявку</button>
|
||||||
|
<p class="status" id="form-status"></p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const modal = document.getElementById("request-modal");
|
||||||
|
const openButtons = document.querySelectorAll("[data-open-modal]");
|
||||||
|
const closeButtons = document.querySelectorAll("[data-close-modal]");
|
||||||
|
const form = document.getElementById("request-form");
|
||||||
|
const status = document.getElementById("form-status");
|
||||||
|
const quoteText = document.getElementById("quote-text");
|
||||||
|
const quoteMeta = document.getElementById("quote-meta");
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
modal.classList.add("open");
|
||||||
|
modal.setAttribute("aria-hidden", "false");
|
||||||
|
document.body.classList.add("modal-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modal.classList.remove("open");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
document.body.classList.remove("modal-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
openButtons.forEach((button) => button.addEventListener("click", openModal));
|
||||||
|
closeButtons.forEach((button) => button.addEventListener("click", closeModal));
|
||||||
|
|
||||||
|
modal.addEventListener("click", (event) => {
|
||||||
|
if (event.target === modal) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && modal.classList.contains("open")) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadQuotes() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/public/quotes?limit=8&order=random");
|
||||||
|
if (!response.ok) throw new Error("quotes fetch failed");
|
||||||
|
const items = await response.json();
|
||||||
|
if (!Array.isArray(items) || items.length === 0) throw new Error("quotes empty");
|
||||||
|
let index = 0;
|
||||||
|
const render = () => {
|
||||||
|
const quote = items[index % items.length];
|
||||||
|
quoteText.textContent = quote.text;
|
||||||
|
quoteMeta.textContent = [quote.author, quote.source].filter(Boolean).join(" • ");
|
||||||
|
index += 1;
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
if (items.length > 1) setInterval(render, 5500);
|
||||||
|
} catch (error) {
|
||||||
|
quoteText.textContent = "С вами работает дружный коллектив профессионалов. Мы уверены в вашем успехе.";
|
||||||
|
quoteMeta.textContent = "Команда компании";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
status.className = "status";
|
||||||
|
status.textContent = "Отправляем заявку...";
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
client_name: document.getElementById("name").value.trim(),
|
||||||
|
client_phone: document.getElementById("phone").value.trim(),
|
||||||
|
topic_code: "consulting",
|
||||||
|
description: document.getElementById("description").value.trim(),
|
||||||
|
extra_fields: {
|
||||||
|
referral_name: document.getElementById("referral").value.trim()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/public/requests", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("create request failed");
|
||||||
|
const data = await response.json();
|
||||||
|
status.className = "status ok";
|
||||||
|
status.textContent = "Заявка принята. Номер: " + data.track_number;
|
||||||
|
form.reset();
|
||||||
|
setTimeout(closeModal, 1200);
|
||||||
|
} catch (error) {
|
||||||
|
status.className = "status error";
|
||||||
|
status.textContent = "Не удалось отправить заявку. Повторите попытку позже.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadQuotes();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
app/workers/celery_app.py
Normal file
12
app/workers/celery_app.py
Normal file
|
|
@ -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"
|
||||||
5
app/workers/tasks/assign.py
Normal file
5
app/workers/tasks/assign.py
Normal file
|
|
@ -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'
|
||||||
5
app/workers/tasks/security.py
Normal file
5
app/workers/tasks/security.py
Normal file
|
|
@ -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'
|
||||||
5
app/workers/tasks/sla.py
Normal file
5
app/workers/tasks/sla.py
Normal file
|
|
@ -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'
|
||||||
5
app/workers/tasks/uploads.py
Normal file
5
app/workers/tasks/uploads.py
Normal file
|
|
@ -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'
|
||||||
BIN
celerybeat-schedule
Normal file
BIN
celerybeat-schedule
Normal file
Binary file not shown.
19
context/00_system_overview.md
Normal file
19
context/00_system_overview.md
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# System Overview Context (Global)
|
||||||
|
|
||||||
|
## Project
|
||||||
|
Legal Case Tracker (Russia)
|
||||||
|
One-page landing + public case tracking (OTP + JWT cookie) + admin panel (ADMIN/LAWYER) + files (S3 self-hosted) + SLA/auto-assign (Celery) + quotes carousel.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
- All infrastructure self-hosted (including S3: MinIO/Ceph)
|
||||||
|
- Backend: Python 3.12 + FastAPI
|
||||||
|
- DB: PostgreSQL
|
||||||
|
- Queue: Redis + Celery
|
||||||
|
- Immutable data after status change
|
||||||
|
- Full audit log for admin changes
|
||||||
|
- UniversalTable + UniversalRecordModal (meta-driven admin UI)
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
- PUBLIC (via OTP + cookie)
|
||||||
|
- LAWYER
|
||||||
|
- ADMIN
|
||||||
18
context/01_public_requests_service.md
Normal file
18
context/01_public_requests_service.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Public Requests Service Context
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
- Accept new legal case requests
|
||||||
|
- Generate track_number
|
||||||
|
- Store configurable form fields (form_fields table)
|
||||||
|
- Trigger OTP flow
|
||||||
|
- Allow client to view request (after OTP verify)
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
- Phone is mandatory
|
||||||
|
- Extra fields stored as JSON (validated against form_fields config)
|
||||||
|
- File size limit: 25MB per file
|
||||||
|
- Case size limit: 350MB total
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Rate limit by IP/phone/track_number
|
||||||
|
- No direct access without OTP verification
|
||||||
18
context/02_otp_service.md
Normal file
18
context/02_otp_service.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# OTP Service Context
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Secure access for:
|
||||||
|
- Creating request
|
||||||
|
- Viewing request
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
1. Send OTP (CREATE_REQUEST / VIEW_REQUEST)
|
||||||
|
2. Store hashed code
|
||||||
|
3. Expire in 10 minutes
|
||||||
|
4. Max attempts limit
|
||||||
|
5. On verify -> issue public JWT cookie (7 days)
|
||||||
|
|
||||||
|
## Anti-abuse
|
||||||
|
- Rate limit (Redis)
|
||||||
|
- Cooldown between sends
|
||||||
|
- Lock after N failed attempts
|
||||||
17
context/03_admin_panel_service.md
Normal file
17
context/03_admin_panel_service.md
Normal file
|
|
@ -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
|
||||||
12
context/04_files_service.md
Normal file
12
context/04_files_service.md
Normal file
|
|
@ -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
|
||||||
23
context/05_sla_auto_assign_service.md
Normal file
23
context/05_sla_auto_assign_service.md
Normal file
|
|
@ -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
|
||||||
20
context/06_quotes_service.md
Normal file
20
context/06_quotes_service.md
Normal file
|
|
@ -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
|
||||||
20
context/07_universal_query_engine.md
Normal file
20
context/07_universal_query_engine.md
Normal file
|
|
@ -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
|
||||||
16
context/08_security_model.md
Normal file
16
context/08_security_model.md
Normal file
|
|
@ -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
|
||||||
14
context/09_metrics_dashboard.md
Normal file
14
context/09_metrics_dashboard.md
Normal file
|
|
@ -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
|
||||||
61
docker-compose.yml
Normal file
61
docker-compose.yml
Normal file
|
|
@ -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:
|
||||||
18
docs/architecture_fastapi_celery.md
Normal file
18
docs/architecture_fastapi_celery.md
Normal file
|
|
@ -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)
|
||||||
41
docs/openapi.yaml
Normal file
41
docs/openapi.yaml
Normal file
|
|
@ -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" } }
|
||||||
4
frontend/Dockerfile
Normal file
4
frontend/Dockerfile
Normal file
|
|
@ -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
|
||||||
35
frontend/nginx.conf
Normal file
35
frontend/nginx.conf
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
requirements.txt
Normal file
14
requirements.txt
Normal file
|
|
@ -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
|
||||||
101
tests/test_migrations.py
Normal file
101
tests/test_migrations.py
Normal file
|
|
@ -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")
|
||||||
87
tests/test_public_requests.py
Normal file
87
tests/test_public_requests.py
Normal file
|
|
@ -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"])
|
||||||
Loading…
Reference in a new issue