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