mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 18:13:46 +03:00
Test-3 commit
This commit is contained in:
parent
7b6fd8c7c2
commit
90450b8918
50 changed files with 5202 additions and 714 deletions
106
alembic/versions/0015_add_clients_table_and_links.py
Normal file
106
alembic/versions/0015_add_clients_table_and_links.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""add clients table and links from requests/invoices
|
||||
|
||||
Revision ID: 0015_clients_table_links
|
||||
Revises: 0014_security_audit_log
|
||||
Create Date: 2026-02-24
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0015_clients_table_links"
|
||||
down_revision = "0014_security_audit_log"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"clients",
|
||||
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("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
|
||||
sa.Column("full_name", sa.String(length=200), nullable=False),
|
||||
sa.Column("phone", sa.String(length=30), nullable=False, unique=True),
|
||||
)
|
||||
op.create_index("ix_clients_phone", "clients", ["phone"], unique=True)
|
||||
|
||||
op.add_column("requests", sa.Column("client_id", postgresql.UUID(as_uuid=True), nullable=True))
|
||||
op.add_column("invoices", sa.Column("client_id", postgresql.UUID(as_uuid=True), nullable=True))
|
||||
op.create_index("ix_requests_client_id", "requests", ["client_id"])
|
||||
op.create_index("ix_invoices_client_id", "invoices", ["client_id"])
|
||||
op.create_foreign_key("fk_requests_client_id_clients", "requests", "clients", ["client_id"], ["id"])
|
||||
op.create_foreign_key("fk_invoices_client_id_clients", "invoices", "clients", ["client_id"], ["id"])
|
||||
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT DISTINCT ON (client_phone)
|
||||
client_phone,
|
||||
client_name
|
||||
FROM requests
|
||||
WHERE client_phone IS NOT NULL AND client_phone <> ''
|
||||
ORDER BY client_phone, created_at ASC NULLS FIRST
|
||||
"""
|
||||
)
|
||||
).mappings()
|
||||
|
||||
for row in rows:
|
||||
client_id = uuid.uuid4()
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO clients (id, created_at, updated_at, responsible, full_name, phone)
|
||||
VALUES (:id, now(), now(), :responsible, :full_name, :phone)
|
||||
ON CONFLICT (phone) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": client_id,
|
||||
"responsible": "Миграция системы",
|
||||
"full_name": str(row.get("client_name") or "").strip() or "Клиент",
|
||||
"phone": str(row.get("client_phone") or "").strip(),
|
||||
},
|
||||
)
|
||||
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE requests AS r
|
||||
SET client_id = c.id
|
||||
FROM clients AS c
|
||||
WHERE r.client_phone = c.phone
|
||||
AND (r.client_id IS NULL OR r.client_id <> c.id)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE invoices AS i
|
||||
SET client_id = r.client_id
|
||||
FROM requests AS r
|
||||
WHERE i.request_id = r.id
|
||||
AND (i.client_id IS NULL OR i.client_id <> r.client_id)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_constraint("fk_invoices_client_id_clients", "invoices", type_="foreignkey")
|
||||
op.drop_constraint("fk_requests_client_id_clients", "requests", type_="foreignkey")
|
||||
op.drop_index("ix_invoices_client_id", table_name="invoices")
|
||||
op.drop_index("ix_requests_client_id", table_name="requests")
|
||||
op.drop_column("invoices", "client_id")
|
||||
op.drop_column("requests", "client_id")
|
||||
op.drop_index("ix_clients_phone", table_name="clients")
|
||||
op.drop_table("clients")
|
||||
38
alembic/versions/0016_add_table_availability.py
Normal file
38
alembic/versions/0016_add_table_availability.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""add table availability controls for dictionaries
|
||||
|
||||
Revision ID: 0016_table_availability
|
||||
Revises: 0015_clients_table_links
|
||||
Create Date: 2026-02-24
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "0016_table_availability"
|
||||
down_revision = "0015_clients_table_links"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"table_availability",
|
||||
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("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
|
||||
sa.Column("table_name", sa.String(length=120), nullable=False, unique=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.create_index("ix_table_availability_table_name", "table_availability", ["table_name"], unique=True)
|
||||
op.create_index("ix_table_availability_is_active", "table_availability", ["is_active"], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_table_availability_is_active", table_name="table_availability")
|
||||
op.drop_index("ix_table_availability_table_name", table_name="table_availability")
|
||||
op.drop_table("table_availability")
|
||||
|
|
@ -5,13 +5,16 @@ 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
|
||||
from app.services.admin_bootstrap import ensure_bootstrap_admin_for_login, get_active_admin_by_email, normalize_admin_email
|
||||
|
||||
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()
|
||||
email = normalize_admin_email(payload.email)
|
||||
user = ensure_bootstrap_admin_for_login(db, email, payload.password)
|
||||
if user is None:
|
||||
user = get_active_admin_by_email(db, email)
|
||||
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},
|
||||
|
|
|
|||
86
app/api/admin/chat.py
Normal file
86
app/api/admin/chat.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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
|
||||
from app.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _request_uuid_or_400(request_id: str) -> UUID:
|
||||
try:
|
||||
return UUID(str(request_id))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки")
|
||||
|
||||
|
||||
def _request_for_id_or_404(db: Session, request_id: str) -> Request:
|
||||
req = db.get(Request, _request_uuid_or_400(request_id))
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
return req
|
||||
|
||||
|
||||
def _ensure_lawyer_can_view_request_or_403(admin: dict, req: Request) -> None:
|
||||
role = str(admin.get("role") or "").upper()
|
||||
if role != "LAWYER":
|
||||
return
|
||||
actor = str(admin.get("sub") or "").strip()
|
||||
if not actor:
|
||||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||
assigned = str(req.assigned_lawyer_id or "").strip()
|
||||
if assigned and actor != assigned:
|
||||
raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки")
|
||||
|
||||
|
||||
def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
|
||||
role = str(admin.get("role") or "").upper()
|
||||
if role != "LAWYER":
|
||||
return
|
||||
actor = str(admin.get("sub") or "").strip()
|
||||
if not actor:
|
||||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||
assigned = str(req.assigned_lawyer_id or "").strip()
|
||||
if not assigned or actor != assigned:
|
||||
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
|
||||
|
||||
|
||||
@router.get("/requests/{request_id}/messages")
|
||||
def list_request_messages(
|
||||
request_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||
):
|
||||
req = _request_for_id_or_404(db, request_id)
|
||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
rows = list_messages_for_request(db, req.id)
|
||||
return {"rows": [serialize_message(row) for row in rows], "total": len(rows)}
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/messages", status_code=201)
|
||||
def create_request_message(
|
||||
request_id: str,
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||
):
|
||||
req = _request_for_id_or_404(db, request_id)
|
||||
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
body = str((payload or {}).get("body") or "").strip()
|
||||
role = str(admin.get("role") or "").upper()
|
||||
actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор")
|
||||
row = create_admin_or_lawyer_message(
|
||||
db,
|
||||
request=req,
|
||||
body=body,
|
||||
actor_role=role,
|
||||
actor_name=actor_name,
|
||||
actor_admin_user_id=str(admin.get("sub") or "").strip() or None,
|
||||
)
|
||||
return serialize_message(row)
|
||||
|
|
@ -3,12 +3,13 @@ from __future__ import annotations
|
|||
import importlib
|
||||
import pkgutil
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.inspection import inspect as sa_inspect
|
||||
|
|
@ -22,6 +23,8 @@ from app.db.session import Base, get_db
|
|||
from app.models.admin_user import AdminUser
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.form_field import FormField
|
||||
from app.models.client import Client
|
||||
from app.models.table_availability import TableAvailability
|
||||
from app.models.request_data_requirement import RequestDataRequirement
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.message import Message
|
||||
|
|
@ -66,6 +69,8 @@ SYSTEM_FIELDS = {
|
|||
"lawyer_unread_event_type",
|
||||
}
|
||||
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
|
||||
REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"}
|
||||
INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"}
|
||||
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
|
||||
|
||||
# Per-table RBAC: table -> role -> actions.
|
||||
|
|
@ -75,10 +80,20 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
|
|||
"ADMIN": set(CRUD_ACTIONS),
|
||||
"LAWYER": set(CRUD_ACTIONS),
|
||||
},
|
||||
"messages": {
|
||||
"ADMIN": set(CRUD_ACTIONS),
|
||||
"LAWYER": {"query", "read", "create"},
|
||||
},
|
||||
"attachments": {
|
||||
"ADMIN": set(CRUD_ACTIONS),
|
||||
"LAWYER": {"query", "read"},
|
||||
},
|
||||
"quotes": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"topics": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"statuses": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"form_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"clients": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"table_availability": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"audit_log": {"ADMIN": {"query", "read"}},
|
||||
"security_audit_log": {"ADMIN": {"query", "read"}},
|
||||
"otp_sessions": {"ADMIN": {"query", "read"}},
|
||||
|
|
@ -172,6 +187,16 @@ def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
|
|||
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
|
||||
|
||||
|
||||
def _request_for_related_row_or_404(db: Session, row: Any) -> Request:
|
||||
request_id = getattr(row, "request_id", None)
|
||||
if request_id is None:
|
||||
raise HTTPException(status_code=400, detail="Связанная заявка не найдена")
|
||||
req = db.get(Request, request_id)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
return req
|
||||
|
||||
|
||||
def _serialize_value(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: _serialize_value(val) for key, val in value.items()}
|
||||
|
|
@ -231,6 +256,8 @@ def _table_label(table_name: str) -> str:
|
|||
"topics": "Темы",
|
||||
"statuses": "Статусы",
|
||||
"form_fields": "Поля формы",
|
||||
"clients": "Клиенты",
|
||||
"table_availability": "Доступность таблиц",
|
||||
"topic_required_fields": "Обязательные поля темы",
|
||||
"topic_data_templates": "Шаблоны данных темы",
|
||||
"topic_status_transitions": "Переходы статусов темы",
|
||||
|
|
@ -341,6 +368,7 @@ def _column_label(table_name: str, column_name: str) -> str:
|
|||
"amount": "Сумма",
|
||||
"currency": "Валюта",
|
||||
"client_name": "Клиент",
|
||||
"client_id": "Клиент (ID)",
|
||||
"client_phone": "Телефон",
|
||||
"payer_display_name": "Плательщик",
|
||||
"payer_details_encrypted": "Реквизиты (шифр.)",
|
||||
|
|
@ -401,6 +429,7 @@ def _column_label(table_name: str, column_name: str) -> str:
|
|||
"reason": "Причина",
|
||||
"diff": "Изменения",
|
||||
"details": "Детали",
|
||||
"table_name": "Таблица",
|
||||
}
|
||||
if normalized_column in explicit:
|
||||
return explicit[normalized_column]
|
||||
|
|
@ -459,6 +488,10 @@ def _hidden_response_fields(table_name: str) -> set[str]:
|
|||
def _protected_input_fields(table_name: str) -> set[str]:
|
||||
if table_name == "admin_users":
|
||||
return {"password_hash"}
|
||||
if table_name == "requests":
|
||||
return {"client_id", *REQUEST_CALCULATED_FIELDS}
|
||||
if table_name == "invoices":
|
||||
return {"client_id", *INVOICE_CALCULATED_FIELDS}
|
||||
return set()
|
||||
|
||||
|
||||
|
|
@ -552,6 +585,52 @@ def _normalize_optional_string(value: Any) -> str | None:
|
|||
return text or None
|
||||
|
||||
|
||||
def _normalize_client_phone(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
allowed = {"+", "(", ")", "-", " "}
|
||||
return "".join(ch for ch in text if ch.isdigit() or ch in allowed).strip()
|
||||
|
||||
|
||||
def _upsert_client_or_400(db: Session, *, full_name: Any, phone: Any, responsible: str) -> Client:
|
||||
normalized_phone = _normalize_client_phone(phone)
|
||||
if not normalized_phone:
|
||||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно')
|
||||
normalized_name = str(full_name or "").strip() or "Клиент"
|
||||
|
||||
row = db.query(Client).filter(Client.phone == normalized_phone).first()
|
||||
if row is None:
|
||||
row = Client(
|
||||
full_name=normalized_name,
|
||||
phone=normalized_phone,
|
||||
responsible=responsible or "Администратор системы",
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
changed = False
|
||||
if normalized_name and row.full_name != normalized_name:
|
||||
row.full_name = normalized_name
|
||||
changed = True
|
||||
if responsible and row.responsible != responsible:
|
||||
row.responsible = responsible
|
||||
changed = True
|
||||
if changed:
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _request_for_uuid_or_400(db: Session, raw_request_id: Any) -> Request:
|
||||
request_uuid = _parse_uuid_or_400(raw_request_id, "request_id")
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=400, detail="Заявка не найдена")
|
||||
return req
|
||||
|
||||
|
||||
def _active_lawyer_or_400(db: Session, lawyer_id: Any) -> AdminUser:
|
||||
lawyer_uuid = _parse_uuid_or_400(lawyer_id, "assigned_lawyer_id")
|
||||
lawyer = db.get(AdminUser, lawyer_uuid)
|
||||
|
|
@ -955,23 +1034,49 @@ def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin:
|
|||
)
|
||||
|
||||
|
||||
@router.get("/meta/tables")
|
||||
def list_tables_meta(admin: dict = Depends(get_current_admin)):
|
||||
role = str(admin.get("role") or "").upper()
|
||||
if role != "ADMIN":
|
||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||
def _table_section(table_name: str) -> str:
|
||||
if table_name in {"requests", "invoices"}:
|
||||
return "main"
|
||||
if table_name == "table_availability":
|
||||
return "system"
|
||||
return "dictionary"
|
||||
|
||||
|
||||
def _table_availability_map(db: Session) -> dict[str, TableAvailability]:
|
||||
rows = db.query(TableAvailability).all()
|
||||
return {str(row.table_name): row for row in rows if row and row.table_name}
|
||||
|
||||
|
||||
def _table_is_active(table_name: str, availability: dict[str, TableAvailability]) -> bool:
|
||||
row = availability.get(table_name)
|
||||
if row is None:
|
||||
return True
|
||||
return bool(row.is_active)
|
||||
|
||||
|
||||
def _meta_tables_payload(
|
||||
db: Session,
|
||||
*,
|
||||
role: str,
|
||||
include_inactive_dictionaries: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
table_models = _table_model_map()
|
||||
availability = _table_availability_map(db)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for table_name in sorted(table_models.keys()):
|
||||
model = table_models[table_name]
|
||||
section = _table_section(table_name)
|
||||
is_active = _table_is_active(table_name, availability)
|
||||
if section == "dictionary" and not include_inactive_dictionaries and not is_active:
|
||||
continue
|
||||
actions = sorted(_allowed_actions(role, table_name))
|
||||
rows.append(
|
||||
{
|
||||
"key": table_name,
|
||||
"table": table_name,
|
||||
"label": _table_label(table_name),
|
||||
"section": "main" if table_name in {"requests", "invoices"} else "dictionary",
|
||||
"section": section,
|
||||
"is_active": is_active,
|
||||
"actions": actions,
|
||||
"query_endpoint": f"/api/admin/crud/{table_name}/query",
|
||||
"create_endpoint": f"/api/admin/crud/{table_name}",
|
||||
|
|
@ -981,8 +1086,80 @@ def list_tables_meta(admin: dict = Depends(get_current_admin)):
|
|||
"columns": _table_columns_meta(table_name, model),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
return {"tables": rows}
|
||||
|
||||
class TableAvailabilityUpdatePayload(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
@router.get("/meta/tables")
|
||||
def list_tables_meta(db: Session = Depends(get_db), admin: dict = Depends(get_current_admin)):
|
||||
role = str(admin.get("role") or "").upper()
|
||||
if role != "ADMIN":
|
||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||
return {"tables": _meta_tables_payload(db, role=role, include_inactive_dictionaries=False)}
|
||||
|
||||
|
||||
@router.get("/meta/available-tables")
|
||||
def list_available_tables(db: Session = Depends(get_db), admin: dict = Depends(get_current_admin)):
|
||||
role = str(admin.get("role") or "").upper()
|
||||
if role != "ADMIN":
|
||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||
|
||||
availability = _table_availability_map(db)
|
||||
rows = []
|
||||
for item in _meta_tables_payload(db, role=role, include_inactive_dictionaries=True):
|
||||
table_name = str(item.get("table") or "")
|
||||
state = availability.get(table_name)
|
||||
rows.append(
|
||||
{
|
||||
"table": table_name,
|
||||
"label": item.get("label"),
|
||||
"section": item.get("section"),
|
||||
"is_active": bool(item.get("is_active")),
|
||||
"responsible": state.responsible if state is not None else None,
|
||||
"updated_at": _serialize_value(state.updated_at) if state is not None else None,
|
||||
}
|
||||
)
|
||||
return {"rows": rows, "total": len(rows)}
|
||||
|
||||
|
||||
@router.patch("/meta/available-tables/{table_name}")
|
||||
def update_available_table(
|
||||
table_name: str,
|
||||
payload: TableAvailabilityUpdatePayload,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
role = str(admin.get("role") or "").upper()
|
||||
if role != "ADMIN":
|
||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||
|
||||
normalized, _ = _resolve_table_model(table_name)
|
||||
row = db.query(TableAvailability).filter(TableAvailability.table_name == normalized).first()
|
||||
responsible = _resolve_responsible(admin)
|
||||
is_active = bool(payload.is_active)
|
||||
if row is None:
|
||||
row = TableAvailability(
|
||||
table_name=normalized,
|
||||
is_active=is_active,
|
||||
responsible=responsible,
|
||||
)
|
||||
db.add(row)
|
||||
else:
|
||||
row.is_active = is_active
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
row.responsible = responsible
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {
|
||||
"table": normalized,
|
||||
"is_active": bool(row.is_active),
|
||||
"responsible": row.responsible,
|
||||
"updated_at": _serialize_value(row.updated_at),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{table_name}/query")
|
||||
|
|
@ -1003,6 +1180,22 @@ def query_table(
|
|||
Request.assigned_lawyer_id.is_(None),
|
||||
)
|
||||
)
|
||||
if normalized == "messages" and _is_lawyer(admin):
|
||||
actor_id = _lawyer_actor_id_or_401(admin)
|
||||
base_query = base_query.join(Request, Request.id == Message.request_id).filter(
|
||||
or_(
|
||||
Request.assigned_lawyer_id == actor_id,
|
||||
Request.assigned_lawyer_id.is_(None),
|
||||
)
|
||||
)
|
||||
if normalized == "attachments" and _is_lawyer(admin):
|
||||
actor_id = _lawyer_actor_id_or_401(admin)
|
||||
base_query = base_query.join(Request, Request.id == Attachment.request_id).filter(
|
||||
or_(
|
||||
Request.assigned_lawyer_id == actor_id,
|
||||
Request.assigned_lawyer_id.is_(None),
|
||||
)
|
||||
)
|
||||
query = apply_universal_query(base_query, model, uq)
|
||||
total = query.count()
|
||||
rows = query.offset(uq.page.offset).limit(uq.page.limit).all()
|
||||
|
|
@ -1039,6 +1232,12 @@ def get_row(
|
|||
db.commit()
|
||||
db.refresh(req)
|
||||
row = req
|
||||
if normalized == "messages" and isinstance(row, Message):
|
||||
req = _request_for_related_row_or_404(db, row)
|
||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
if normalized == "attachments" and isinstance(row, Attachment):
|
||||
req = _request_for_related_row_or_404(db, row)
|
||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
return _strip_hidden_fields(normalized, _row_to_dict(row))
|
||||
|
||||
|
||||
|
|
@ -1051,6 +1250,9 @@ def create_row(
|
|||
):
|
||||
normalized, model = _resolve_table_model(table_name)
|
||||
_require_table_action(admin, normalized, "create")
|
||||
responsible = _resolve_responsible(admin)
|
||||
resolved_request_client_id: uuid.UUID | None = None
|
||||
resolved_invoice_client_id: uuid.UUID | None = None
|
||||
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
|
||||
assigned_lawyer_id = payload.get("assigned_lawyer_id")
|
||||
if str(assigned_lawyer_id or "").strip():
|
||||
|
|
@ -1060,8 +1262,28 @@ def create_row(
|
|||
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||||
|
||||
prepared = _prepare_create_payload(normalized, payload)
|
||||
if normalized == "messages":
|
||||
request_uuid = _parse_uuid_or_400(prepared.get("request_id"), "request_id")
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
if _is_lawyer(admin):
|
||||
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
prepared["author_type"] = "LAWYER"
|
||||
prepared["author_name"] = str(admin.get("email") or "").strip() or "Юрист"
|
||||
prepared["immutable"] = False
|
||||
prepared["request_id"] = request_uuid
|
||||
if normalized == "requests":
|
||||
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
|
||||
client = _upsert_client_or_400(
|
||||
db,
|
||||
full_name=prepared.get("client_name"),
|
||||
phone=prepared.get("client_phone"),
|
||||
responsible=responsible,
|
||||
)
|
||||
resolved_request_client_id = client.id
|
||||
prepared["client_name"] = client.full_name
|
||||
prepared["client_phone"] = client.phone
|
||||
if not _is_lawyer(admin):
|
||||
assigned_raw = prepared.get("assigned_lawyer_id")
|
||||
if assigned_raw is None or not str(assigned_raw).strip():
|
||||
|
|
@ -1072,6 +1294,10 @@ def create_row(
|
|||
prepared["assigned_lawyer_id"] = str(assigned_lawyer.id)
|
||||
if prepared.get("effective_rate") is None:
|
||||
prepared["effective_rate"] = assigned_lawyer.default_rate
|
||||
if normalized == "invoices":
|
||||
req = _request_for_uuid_or_400(db, prepared.get("request_id"))
|
||||
prepared["request_id"] = req.id
|
||||
resolved_invoice_client_id = req.client_id
|
||||
prepared = _apply_auto_fields_for_create(db, model, normalized, prepared)
|
||||
clean_payload = _sanitize_payload(
|
||||
model,
|
||||
|
|
@ -1092,8 +1318,12 @@ def create_row(
|
|||
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||
if normalized == "statuses":
|
||||
clean_payload = _apply_status_fields(clean_payload)
|
||||
if normalized == "requests":
|
||||
clean_payload["client_id"] = resolved_request_client_id
|
||||
if normalized == "invoices":
|
||||
clean_payload["client_id"] = resolved_invoice_client_id
|
||||
if "responsible" in _columns_map(model):
|
||||
clean_payload["responsible"] = _resolve_responsible(admin)
|
||||
clean_payload["responsible"] = responsible
|
||||
row = model(**clean_payload)
|
||||
|
||||
try:
|
||||
|
|
@ -1121,6 +1351,7 @@ def update_row(
|
|||
):
|
||||
normalized, model = _resolve_table_model(table_name)
|
||||
_require_table_action(admin, normalized, "update")
|
||||
responsible = _resolve_responsible(admin)
|
||||
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
|
||||
if "assigned_lawyer_id" in payload:
|
||||
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
||||
|
|
@ -1154,6 +1385,26 @@ def update_row(
|
|||
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||
if normalized == "statuses":
|
||||
clean_payload = _apply_status_fields(clean_payload)
|
||||
if normalized == "requests" and isinstance(row, Request):
|
||||
if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None:
|
||||
client = _upsert_client_or_400(
|
||||
db,
|
||||
full_name=clean_payload.get("client_name", row.client_name),
|
||||
phone=clean_payload.get("client_phone", row.client_phone),
|
||||
responsible=responsible,
|
||||
)
|
||||
clean_payload["client_id"] = client.id
|
||||
clean_payload["client_name"] = client.full_name
|
||||
clean_payload["client_phone"] = client.phone
|
||||
if normalized == "invoices":
|
||||
if "request_id" in clean_payload:
|
||||
req = _request_for_uuid_or_400(db, clean_payload.get("request_id"))
|
||||
clean_payload["request_id"] = req.id
|
||||
clean_payload["client_id"] = req.client_id
|
||||
elif getattr(row, "client_id", None) is None:
|
||||
req = db.get(Request, getattr(row, "request_id", None))
|
||||
if req is not None:
|
||||
clean_payload["client_id"] = req.client_id
|
||||
if normalized == "requests" and not _is_lawyer(admin) and "assigned_lawyer_id" in clean_payload:
|
||||
assigned_raw = clean_payload.get("assigned_lawyer_id")
|
||||
if assigned_raw is None or not str(assigned_raw).strip():
|
||||
|
|
@ -1163,6 +1414,8 @@ def update_row(
|
|||
clean_payload["assigned_lawyer_id"] = str(assigned_lawyer.id)
|
||||
if isinstance(row, Request) and row.effective_rate is None and "effective_rate" not in clean_payload:
|
||||
clean_payload["effective_rate"] = assigned_lawyer.default_rate
|
||||
if "responsible" in _columns_map(model):
|
||||
clean_payload["responsible"] = responsible
|
||||
before = _row_to_dict(row)
|
||||
if normalized == "topic_status_transitions":
|
||||
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
|
||||
|
|
@ -1185,7 +1438,7 @@ def update_row(
|
|||
from_status=before_status,
|
||||
to_status=after_status,
|
||||
admin=admin,
|
||||
responsible=_resolve_responsible(admin),
|
||||
responsible=responsible,
|
||||
)
|
||||
mark_unread_for_client(row, EVENT_STATUS)
|
||||
apply_status_change_effects(
|
||||
|
|
@ -1194,7 +1447,7 @@ def update_row(
|
|||
from_status=before_status,
|
||||
to_status=after_status,
|
||||
admin=admin,
|
||||
responsible=_resolve_responsible(admin),
|
||||
responsible=responsible,
|
||||
)
|
||||
notify_request_event(
|
||||
db,
|
||||
|
|
@ -1203,7 +1456,7 @@ def update_row(
|
|||
actor_role=_actor_role(admin),
|
||||
actor_admin_user_id=admin.get("sub"),
|
||||
body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")),
|
||||
responsible=_resolve_responsible(admin),
|
||||
responsible=responsible,
|
||||
)
|
||||
for key, value in clean_payload.items():
|
||||
setattr(row, key, value)
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ def _serialize_invoice(
|
|||
"id": str(row.id),
|
||||
"invoice_number": row.invoice_number,
|
||||
"request_id": str(row.request_id),
|
||||
"client_id": str(row.client_id) if row.client_id else None,
|
||||
"request_track_number": request_track,
|
||||
"status": row.status,
|
||||
"status_label": STATUS_LABELS.get(str(row.status or "").upper(), row.status),
|
||||
|
|
@ -275,6 +276,7 @@ def create_invoice(
|
|||
|
||||
invoice = Invoice(
|
||||
request_id=req.id,
|
||||
client_id=req.client_id,
|
||||
invoice_number=str(payload.get("invoice_number") or "").strip() or _invoice_number(db),
|
||||
status=status,
|
||||
amount=_amount_or_400(payload.get("amount")),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ from app.models.admin_user import AdminUser
|
|||
from app.models.audit_log import AuditLog
|
||||
from app.models.request_data_requirement import RequestDataRequirement
|
||||
from app.models.request import Request
|
||||
from app.models.status import Status
|
||||
from app.models.status_history import StatusHistory
|
||||
from app.models.topic_data_template import TopicDataTemplate
|
||||
from app.models.topic_status_transition import TopicStatusTransition
|
||||
from app.services.notifications import (
|
||||
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||||
mark_admin_notifications_read,
|
||||
|
|
@ -317,6 +320,174 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
|
|||
}
|
||||
|
||||
|
||||
@router.get("/{request_id}/status-route")
|
||||
def get_request_status_route(
|
||||
request_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||
):
|
||||
request_uuid = _request_uuid_or_400(request_id)
|
||||
req = db.get(Request, request_uuid)
|
||||
if not req:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
|
||||
topic_code = str(req.topic_code or "").strip()
|
||||
current_status = str(req.status_code or "").strip()
|
||||
|
||||
transitions: list[TopicStatusTransition] = []
|
||||
if topic_code:
|
||||
transitions = (
|
||||
db.query(TopicStatusTransition)
|
||||
.filter(
|
||||
TopicStatusTransition.topic_code == topic_code,
|
||||
TopicStatusTransition.enabled.is_(True),
|
||||
)
|
||||
.order_by(
|
||||
TopicStatusTransition.sort_order.asc(),
|
||||
TopicStatusTransition.from_status.asc(),
|
||||
TopicStatusTransition.to_status.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
history_rows = (
|
||||
db.query(StatusHistory)
|
||||
.filter(StatusHistory.request_id == req.id)
|
||||
.order_by(StatusHistory.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
known_codes: set[str] = set()
|
||||
if current_status:
|
||||
known_codes.add(current_status)
|
||||
for row in history_rows:
|
||||
from_code = str(row.from_status or "").strip()
|
||||
to_code = str(row.to_status or "").strip()
|
||||
if from_code:
|
||||
known_codes.add(from_code)
|
||||
if to_code:
|
||||
known_codes.add(to_code)
|
||||
for row in transitions:
|
||||
from_code = str(row.from_status or "").strip()
|
||||
to_code = str(row.to_status or "").strip()
|
||||
if from_code:
|
||||
known_codes.add(from_code)
|
||||
if to_code:
|
||||
known_codes.add(to_code)
|
||||
|
||||
statuses_map: dict[str, dict[str, str]] = {}
|
||||
if known_codes:
|
||||
status_rows = db.query(Status).filter(Status.code.in_(list(known_codes))).all()
|
||||
statuses_map = {
|
||||
str(row.code): {
|
||||
"name": str(row.name or row.code),
|
||||
"kind": str(row.kind or "DEFAULT"),
|
||||
}
|
||||
for row in status_rows
|
||||
}
|
||||
|
||||
sequence_from_history: list[str] = []
|
||||
if history_rows:
|
||||
first_from = str(history_rows[0].from_status or "").strip()
|
||||
if first_from:
|
||||
sequence_from_history.append(first_from)
|
||||
for row in history_rows:
|
||||
to_code = str(row.to_status or "").strip()
|
||||
if to_code:
|
||||
sequence_from_history.append(to_code)
|
||||
elif current_status:
|
||||
sequence_from_history.append(current_status)
|
||||
|
||||
ordered_codes: list[str] = []
|
||||
seen_codes: set[str] = set()
|
||||
|
||||
def add_code(code: str) -> None:
|
||||
normalized = str(code or "").strip()
|
||||
if not normalized or normalized in seen_codes:
|
||||
return
|
||||
seen_codes.add(normalized)
|
||||
ordered_codes.append(normalized)
|
||||
|
||||
for code in sequence_from_history:
|
||||
add_code(code)
|
||||
|
||||
for row in transitions:
|
||||
add_code(str(row.from_status or ""))
|
||||
add_code(str(row.to_status or ""))
|
||||
|
||||
add_code(current_status)
|
||||
|
||||
transition_by_to_status: dict[str, dict[str, str | int | None]] = {}
|
||||
for row in transitions:
|
||||
to_code = str(row.to_status or "").strip()
|
||||
if not to_code:
|
||||
continue
|
||||
current = transition_by_to_status.get(to_code)
|
||||
if current is None or int(row.sort_order or 0) < int(current.get("sort_order") or 0):
|
||||
transition_by_to_status[to_code] = {
|
||||
"from_status": str(row.from_status or "").strip() or None,
|
||||
"sla_hours": row.sla_hours,
|
||||
"sort_order": int(row.sort_order or 0),
|
||||
}
|
||||
|
||||
changed_at_by_status: dict[str, str] = {}
|
||||
for row in history_rows:
|
||||
to_code = str(row.to_status or "").strip()
|
||||
if to_code and row.created_at:
|
||||
changed_at_by_status[to_code] = row.created_at.isoformat()
|
||||
|
||||
visited_codes = {code for code in sequence_from_history if code}
|
||||
current_index = ordered_codes.index(current_status) if current_status in ordered_codes else -1
|
||||
|
||||
def status_name(code: str) -> str:
|
||||
meta = statuses_map.get(code) or {}
|
||||
return str(meta.get("name") or code)
|
||||
|
||||
nodes: list[dict[str, str | int | None]] = []
|
||||
for index, code in enumerate(ordered_codes):
|
||||
meta = statuses_map.get(code) or {}
|
||||
transition_meta = transition_by_to_status.get(code) or {}
|
||||
state = "pending"
|
||||
if code == current_status:
|
||||
state = "current"
|
||||
elif code in visited_codes or (current_index >= 0 and index < current_index):
|
||||
state = "completed"
|
||||
|
||||
note_parts: list[str] = []
|
||||
from_status = transition_meta.get("from_status")
|
||||
if from_status:
|
||||
note_parts.append(f"Переход из статуса «{status_name(str(from_status))}»")
|
||||
sla_hours = transition_meta.get("sla_hours")
|
||||
if sla_hours is not None:
|
||||
note_parts.append(f"SLA: {sla_hours} ч")
|
||||
kind = str(meta.get("kind") or "DEFAULT")
|
||||
if kind == "INVOICE":
|
||||
note_parts.append("Этап выставления счета")
|
||||
elif kind == "PAID":
|
||||
note_parts.append("Этап подтверждения оплаты")
|
||||
|
||||
nodes.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": status_name(code),
|
||||
"kind": kind,
|
||||
"state": state,
|
||||
"sla_hours": sla_hours,
|
||||
"changed_at": changed_at_by_status.get(code),
|
||||
"note": " • ".join(note_parts),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"request_id": str(req.id),
|
||||
"track_number": req.track_number,
|
||||
"topic_code": req.topic_code,
|
||||
"current_status": current_status or None,
|
||||
"nodes": nodes,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{request_id}/claim")
|
||||
def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("LAWYER"))):
|
||||
request_uuid = _request_uuid_or_400(request_id)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from fastapi import APIRouter
|
||||
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices
|
||||
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
|
||||
|
|
@ -11,4 +11,5 @@ router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"])
|
|||
router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"])
|
||||
router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"])
|
||||
router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"])
|
||||
router.include_router(chat.router, prefix="/chat", tags=["AdminChat"])
|
||||
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
|
||||
|
|
|
|||
76
app/api/public/chat.py
Normal file
76
app/api/public/chat.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_public_session
|
||||
from app.db.session import get_db
|
||||
from app.models.request import Request
|
||||
from app.schemas.public import PublicMessageCreate
|
||||
from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _normalize_phone(raw: str | None) -> str:
|
||||
value = str(raw or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
allowed = {"+", "(", ")", "-", " "}
|
||||
return "".join(ch for ch in value if ch.isdigit() or ch in allowed).strip()
|
||||
|
||||
|
||||
def _normalize_track(raw: str | None) -> str:
|
||||
return str(raw or "").strip().upper()
|
||||
|
||||
|
||||
def _require_view_session_or_403(session: dict) -> str:
|
||||
purpose = str(session.get("purpose") or "").strip().upper()
|
||||
subject = str(session.get("sub") or "").strip()
|
||||
if purpose != "VIEW_REQUEST" or not subject:
|
||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||
return subject
|
||||
|
||||
|
||||
def _request_for_track_or_404(db: Session, track_number: str) -> Request:
|
||||
req = db.query(Request).filter(Request.track_number == _normalize_track(track_number)).first()
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
return req
|
||||
|
||||
|
||||
def _ensure_view_access_or_403(session: dict, req: Request) -> None:
|
||||
subject = _require_view_session_or_403(session)
|
||||
subject_track = _normalize_track(subject)
|
||||
if subject_track.startswith("TRK-") and subject_track != _normalize_track(req.track_number):
|
||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||
if subject_track == _normalize_track(req.track_number):
|
||||
return
|
||||
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||
|
||||
|
||||
@router.get("/requests/{track_number}/messages")
|
||||
def list_messages_by_track(
|
||||
track_number: str,
|
||||
db: Session = Depends(get_db),
|
||||
session: dict = Depends(get_public_session),
|
||||
):
|
||||
req = _request_for_track_or_404(db, track_number)
|
||||
_ensure_view_access_or_403(session, req)
|
||||
rows = list_messages_for_request(db, req.id)
|
||||
return [serialize_message(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/requests/{track_number}/messages", status_code=201)
|
||||
def create_message_by_track(
|
||||
track_number: str,
|
||||
payload: PublicMessageCreate,
|
||||
db: Session = Depends(get_db),
|
||||
session: dict = Depends(get_public_session),
|
||||
):
|
||||
req = _request_for_track_or_404(db, track_number)
|
||||
_ensure_view_access_or_403(session, req)
|
||||
row = create_client_message(db, request=req, body=payload.body)
|
||||
return serialize_message(row)
|
||||
|
|
@ -136,12 +136,18 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
|||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
|
||||
else:
|
||||
track_number = _normalize_track(payload.track_number)
|
||||
if not track_number:
|
||||
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
||||
phone = _normalize_phone(payload.client_phone)
|
||||
if track_number:
|
||||
request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first()
|
||||
if request_row is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
phone = _normalize_phone(request_row.client_phone)
|
||||
elif phone:
|
||||
has_requests = db.query(RequestModel.id).filter(RequestModel.client_phone == phone).first()
|
||||
if has_requests is None:
|
||||
raise HTTPException(status_code=404, detail="Заявки по номеру телефона не найдены")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"')
|
||||
if not phone:
|
||||
raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона")
|
||||
|
||||
|
|
@ -201,8 +207,9 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses
|
|||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
|
||||
else:
|
||||
track_number = _normalize_track(payload.track_number)
|
||||
if not track_number:
|
||||
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
||||
phone = _normalize_phone(payload.client_phone)
|
||||
if not track_number and not phone:
|
||||
raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"')
|
||||
|
||||
_rate_limit_or_429(
|
||||
"verify",
|
||||
|
|
@ -212,11 +219,10 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses
|
|||
track_number=track_number,
|
||||
)
|
||||
|
||||
query = db.query(OtpSession).filter(
|
||||
OtpSession.purpose == purpose,
|
||||
OtpSession.track_number == track_number,
|
||||
)
|
||||
if phone is not None:
|
||||
query = db.query(OtpSession).filter(OtpSession.purpose == purpose)
|
||||
if track_number is not None and track_number != "":
|
||||
query = query.filter(OtpSession.track_number == track_number)
|
||||
if phone is not None and phone != "":
|
||||
query = query.filter(OtpSession.phone == phone)
|
||||
|
||||
row = query.order_by(OtpSession.created_at.desc()).first()
|
||||
|
|
@ -239,7 +245,15 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses
|
|||
db.commit()
|
||||
raise HTTPException(status_code=400, detail="Неверный OTP-код")
|
||||
|
||||
subject = row.phone if purpose == OTP_CREATE_PURPOSE else str(row.track_number or "")
|
||||
if purpose == OTP_CREATE_PURPOSE:
|
||||
subject = str(row.phone or "")
|
||||
else:
|
||||
if phone:
|
||||
subject = str(row.phone or "")
|
||||
elif track_number:
|
||||
subject = str(row.track_number or "")
|
||||
else:
|
||||
subject = str(row.phone or row.track_number or "")
|
||||
if not subject:
|
||||
raise HTTPException(status_code=400, detail="Некорректная OTP-сессия")
|
||||
|
||||
|
|
|
|||
|
|
@ -14,21 +14,22 @@ from app.core.security import create_jwt
|
|||
from app.db.session import get_db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.client import Client
|
||||
from app.models.invoice import Invoice
|
||||
from app.models.message import Message
|
||||
from app.models.request import Request
|
||||
from app.models.status_history import StatusHistory
|
||||
from app.models.topic import Topic
|
||||
from app.services.invoice_crypto import decrypt_requisites
|
||||
from app.services.invoice_pdf import build_invoice_pdf_bytes
|
||||
from app.services.chat_service import create_client_message, list_messages_for_request
|
||||
from app.services.notifications import (
|
||||
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
|
||||
get_client_notification,
|
||||
list_client_notifications,
|
||||
mark_client_notifications_read,
|
||||
notify_request_event,
|
||||
serialize_notification,
|
||||
)
|
||||
from app.services.request_read_markers import EVENT_MESSAGE, clear_unread_for_client, mark_unread_for_lawyer
|
||||
from app.services.request_read_markers import clear_unread_for_client
|
||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||
from app.schemas.public import (
|
||||
PublicAttachmentRead,
|
||||
|
|
@ -52,16 +53,20 @@ INVOICE_STATUS_LABELS = {
|
|||
|
||||
|
||||
def _normalize_phone(raw: str | None) -> str:
|
||||
return str(raw or "").strip()
|
||||
value = str(raw or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
allowed = {"+", "(", ")", "-", " "}
|
||||
return "".join(ch for ch in value if ch.isdigit() or ch in allowed).strip()
|
||||
|
||||
|
||||
def _normalize_track(raw: str | None) -> str:
|
||||
return str(raw or "").strip().upper()
|
||||
|
||||
|
||||
def _set_view_cookie(response: Response, track_number: str) -> None:
|
||||
def _set_view_cookie(response: Response, subject: str) -> None:
|
||||
token = create_jwt(
|
||||
{"sub": track_number, "purpose": OTP_VIEW_PURPOSE},
|
||||
{"sub": subject, "purpose": OTP_VIEW_PURPOSE},
|
||||
settings.PUBLIC_JWT_SECRET,
|
||||
timedelta(days=settings.PUBLIC_JWT_TTL_DAYS),
|
||||
)
|
||||
|
|
@ -82,22 +87,60 @@ def _require_create_session_or_403(session: dict, client_phone: str) -> None:
|
|||
raise HTTPException(status_code=403, detail="Требуется подтверждение телефона через OTP")
|
||||
|
||||
|
||||
def _require_view_session_for_track_or_403(session: dict, track_number: str) -> None:
|
||||
def _require_view_session_or_403(session: dict) -> str:
|
||||
purpose = str(session.get("purpose") or "").strip().upper()
|
||||
sub = _normalize_track(session.get("sub"))
|
||||
if purpose != OTP_VIEW_PURPOSE or not sub or sub != _normalize_track(track_number):
|
||||
subject = str(session.get("sub") or "").strip()
|
||||
if purpose != OTP_VIEW_PURPOSE or not subject:
|
||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||
return subject
|
||||
|
||||
|
||||
def _ensure_view_access_or_403(session: dict, req: Request) -> None:
|
||||
subject = _require_view_session_or_403(session)
|
||||
if _normalize_track(subject) == _normalize_track(req.track_number):
|
||||
return
|
||||
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||
|
||||
|
||||
def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> Request:
|
||||
normalized_track = _normalize_track(track_number)
|
||||
_require_view_session_for_track_or_403(session, normalized_track)
|
||||
subject = _require_view_session_or_403(session)
|
||||
subject_track = _normalize_track(subject)
|
||||
if subject_track.startswith("TRK-") and subject_track != normalized_track:
|
||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||
req = db.query(Request).filter(Request.track_number == normalized_track).first()
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
_ensure_view_access_or_403(session, req)
|
||||
return req
|
||||
|
||||
|
||||
def _upsert_client_by_phone(db: Session, *, full_name: str, phone: str) -> Client:
|
||||
normalized_phone = _normalize_phone(phone)
|
||||
if not normalized_phone:
|
||||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно')
|
||||
normalized_name = str(full_name or "").strip() or "Клиент"
|
||||
|
||||
client = db.query(Client).filter(Client.phone == normalized_phone).first()
|
||||
if client is None:
|
||||
client = Client(
|
||||
full_name=normalized_name,
|
||||
phone=normalized_phone,
|
||||
responsible="Клиент",
|
||||
)
|
||||
db.add(client)
|
||||
db.flush()
|
||||
return client
|
||||
if client.full_name != normalized_name:
|
||||
client.full_name = normalized_name
|
||||
client.responsible = "Клиент"
|
||||
db.add(client)
|
||||
db.flush()
|
||||
return client
|
||||
|
||||
|
||||
def _to_iso(value) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
|
@ -127,12 +170,14 @@ def create_request(
|
|||
):
|
||||
_require_create_session_or_403(session, payload.client_phone)
|
||||
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
||||
client = _upsert_client_by_phone(db, full_name=payload.client_name, phone=payload.client_phone)
|
||||
|
||||
track = f"TRK-{uuid4().hex[:10].upper()}"
|
||||
row = Request(
|
||||
track_number=track,
|
||||
client_name=payload.client_name,
|
||||
client_phone=payload.client_phone,
|
||||
client_id=client.id,
|
||||
client_name=client.full_name,
|
||||
client_phone=client.phone,
|
||||
topic_code=payload.topic_code,
|
||||
description=payload.description,
|
||||
extra_fields=payload.extra_fields,
|
||||
|
|
@ -142,10 +187,55 @@ def create_request(
|
|||
db.commit()
|
||||
db.refresh(row)
|
||||
|
||||
_set_view_cookie(response, track)
|
||||
_set_view_cookie(response, client.phone)
|
||||
return PublicRequestCreated(request_id=row.id, track_number=row.track_number, otp_required=False)
|
||||
|
||||
|
||||
@router.get("/topics")
|
||||
def list_public_topics(db: Session = Depends(get_db)):
|
||||
rows = (
|
||||
db.query(Topic)
|
||||
.filter(Topic.enabled.is_(True))
|
||||
.order_by(Topic.sort_order.asc(), Topic.name.asc(), Topic.code.asc())
|
||||
.all()
|
||||
)
|
||||
return [{"code": row.code, "name": row.name} for row in rows]
|
||||
|
||||
|
||||
@router.get("/my")
|
||||
def list_my_requests(
|
||||
db: Session = Depends(get_db),
|
||||
session: dict = Depends(get_public_session),
|
||||
):
|
||||
subject = _require_view_session_or_403(session)
|
||||
normalized_track = _normalize_track(subject)
|
||||
normalized_phone = _normalize_phone(subject)
|
||||
|
||||
query = db.query(Request)
|
||||
if normalized_track.startswith("TRK-"):
|
||||
query = query.filter(Request.track_number == normalized_track)
|
||||
else:
|
||||
query = query.filter(Request.client_phone == normalized_phone)
|
||||
|
||||
rows = query.order_by(Request.updated_at.desc(), Request.created_at.desc(), Request.id.desc()).all()
|
||||
return {
|
||||
"rows": [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"track_number": row.track_number,
|
||||
"topic_code": row.topic_code,
|
||||
"status_code": row.status_code,
|
||||
"client_has_unread_updates": bool(row.client_has_unread_updates),
|
||||
"client_unread_event_type": row.client_unread_event_type,
|
||||
"created_at": _to_iso(row.created_at),
|
||||
"updated_at": _to_iso(row.updated_at),
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"total": len(rows),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{track_number}")
|
||||
def get_request_by_track(
|
||||
track_number: str,
|
||||
|
|
@ -167,6 +257,7 @@ def get_request_by_track(
|
|||
|
||||
return {
|
||||
"id": str(req.id),
|
||||
"client_id": str(req.client_id) if req.client_id else None,
|
||||
"track_number": req.track_number,
|
||||
"client_name": req.client_name,
|
||||
"client_phone": req.client_phone,
|
||||
|
|
@ -191,12 +282,7 @@ def list_messages_by_track(
|
|||
session: dict = Depends(get_public_session),
|
||||
):
|
||||
req = _request_for_track_or_404(db, session, track_number)
|
||||
rows = (
|
||||
db.query(Message)
|
||||
.filter(Message.request_id == req.id)
|
||||
.order_by(Message.created_at.asc(), Message.id.asc())
|
||||
.all()
|
||||
)
|
||||
rows = list_messages_for_request(db, req.id)
|
||||
return [
|
||||
PublicMessageRead(
|
||||
id=row.id,
|
||||
|
|
@ -219,31 +305,7 @@ def create_message_by_track(
|
|||
session: dict = Depends(get_public_session),
|
||||
):
|
||||
req = _request_for_track_or_404(db, session, track_number)
|
||||
body = str(payload.body or "").strip()
|
||||
if not body:
|
||||
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
|
||||
|
||||
row = Message(
|
||||
request_id=req.id,
|
||||
author_type="CLIENT",
|
||||
author_name=req.client_name,
|
||||
body=body,
|
||||
responsible="Клиент",
|
||||
)
|
||||
mark_unread_for_lawyer(req, EVENT_MESSAGE)
|
||||
req.responsible = "Клиент"
|
||||
notify_request_event(
|
||||
db,
|
||||
request=req,
|
||||
event_type=NOTIFICATION_EVENT_MESSAGE,
|
||||
actor_role="CLIENT",
|
||||
body=body,
|
||||
responsible="Клиент",
|
||||
)
|
||||
db.add(row)
|
||||
db.add(req)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
row = create_client_message(db, request=req, body=payload.body)
|
||||
|
||||
return PublicMessageRead(
|
||||
id=row.id,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from fastapi import APIRouter
|
||||
from app.api.public import requests, otp, quotes, uploads
|
||||
from app.api.public import requests, otp, quotes, uploads, chat
|
||||
|
||||
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"])
|
||||
router.include_router(chat.router, prefix="/chat", tags=["PublicChat"])
|
||||
|
|
|
|||
|
|
@ -48,8 +48,21 @@ def _ensure_public_request_access_or_403(request: Request, session: dict) -> Non
|
|||
purpose = str(session.get("purpose") or "").strip().upper()
|
||||
if purpose != "VIEW_REQUEST":
|
||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||
track_from_session = str(session.get("sub") or "").strip()
|
||||
if not track_from_session or track_from_session != str(request.track_number):
|
||||
subject = str(session.get("sub") or "").strip()
|
||||
if not subject:
|
||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||
|
||||
normalized_track = str(subject).strip().upper()
|
||||
if normalized_track == str(request.track_number or "").strip().upper():
|
||||
return
|
||||
|
||||
def _normalize_phone(value: str | None) -> str:
|
||||
raw = str(value or "").strip()
|
||||
allowed = {"+", "(", ")", "-", " "}
|
||||
return "".join(ch for ch in raw if ch.isdigit() or ch in allowed).strip()
|
||||
|
||||
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(request.client_phone):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ class Settings(BaseSettings):
|
|||
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
|
||||
OTP_SEND_RATE_LIMIT: int = 8
|
||||
OTP_VERIFY_RATE_LIMIT: int = 20
|
||||
ADMIN_BOOTSTRAP_ENABLED: bool = True
|
||||
ADMIN_BOOTSTRAP_EMAIL: str = "admin@example.com"
|
||||
ADMIN_BOOTSTRAP_PASSWORD: str = "admin123"
|
||||
ADMIN_BOOTSTRAP_NAME: str = "Администратор системы"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> List[str]:
|
||||
|
|
|
|||
14
app/models/client.py
Normal file
14
app/models/client.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.session import Base
|
||||
from app.models.common import TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Client(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "clients"
|
||||
|
||||
full_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
phone: Mapped[str] = mapped_column(String(30), nullable=False, unique=True, index=True)
|
||||
|
|
@ -13,6 +13,7 @@ class Invoice(Base, UUIDMixin, TimestampMixin):
|
|||
__tablename__ = "invoices"
|
||||
|
||||
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||||
client_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
|
||||
invoice_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True, default="WAITING_PAYMENT")
|
||||
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, JSON, Numeric, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.db.session import Base
|
||||
from app.models.common import UUIDMixin, TimestampMixin
|
||||
|
|
@ -8,6 +10,7 @@ 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_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, 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)
|
||||
|
|
|
|||
12
app/models/table_availability.py
Normal file
12
app/models/table_availability.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.session import Base
|
||||
from app.models.common import TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class TableAvailability(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "table_availability"
|
||||
|
||||
table_name: Mapped[str] = mapped_column(String(120), unique=True, index=True, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
71
app/services/admin_bootstrap.py
Normal file
71
app/services/admin_bootstrap.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.models.admin_user import AdminUser
|
||||
|
||||
|
||||
def normalize_admin_email(raw: str | None) -> str:
|
||||
return str(raw or "").strip().lower()
|
||||
|
||||
|
||||
def get_active_admin_by_email(db: Session, email: str) -> AdminUser | None:
|
||||
normalized = normalize_admin_email(email)
|
||||
if not normalized:
|
||||
return None
|
||||
return (
|
||||
db.query(AdminUser)
|
||||
.filter(func.lower(AdminUser.email) == normalized, AdminUser.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_admin_by_email_any_state(db: Session, email: str) -> AdminUser | None:
|
||||
normalized = normalize_admin_email(email)
|
||||
if not normalized:
|
||||
return None
|
||||
return db.query(AdminUser).filter(func.lower(AdminUser.email) == normalized).first()
|
||||
|
||||
|
||||
def ensure_bootstrap_admin_for_login(db: Session, email: str, password: str) -> AdminUser | None:
|
||||
if not settings.ADMIN_BOOTSTRAP_ENABLED:
|
||||
return None
|
||||
|
||||
normalized_email = normalize_admin_email(email)
|
||||
bootstrap_email = normalize_admin_email(settings.ADMIN_BOOTSTRAP_EMAIL)
|
||||
if normalized_email != bootstrap_email:
|
||||
return None
|
||||
if str(password or "") != str(settings.ADMIN_BOOTSTRAP_PASSWORD or ""):
|
||||
return None
|
||||
|
||||
user = get_admin_by_email_any_state(db, bootstrap_email)
|
||||
if user is None:
|
||||
user = AdminUser(
|
||||
role="ADMIN",
|
||||
name=str(settings.ADMIN_BOOTSTRAP_NAME or "Администратор системы"),
|
||||
email=bootstrap_email,
|
||||
password_hash=hash_password(str(settings.ADMIN_BOOTSTRAP_PASSWORD or "")),
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
else:
|
||||
user.role = "ADMIN"
|
||||
user.email = bootstrap_email
|
||||
user.is_active = True
|
||||
if not str(user.name or "").strip():
|
||||
user.name = str(settings.ADMIN_BOOTSTRAP_NAME or "Администратор системы")
|
||||
if not verify_password(str(settings.ADMIN_BOOTSTRAP_PASSWORD or ""), str(user.password_hash or "")):
|
||||
user.password_hash = hash_password(str(settings.ADMIN_BOOTSTRAP_PASSWORD or ""))
|
||||
db.add(user)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
return get_active_admin_by_email(db, bootstrap_email)
|
||||
db.refresh(user)
|
||||
return user
|
||||
105
app/services/chat_service.py
Normal file
105
app/services/chat_service.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.message import Message
|
||||
from app.models.request import Request
|
||||
from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event
|
||||
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
|
||||
|
||||
|
||||
def list_messages_for_request(db: Session, request_id: Any) -> list[Message]:
|
||||
return (
|
||||
db.query(Message)
|
||||
.filter(Message.request_id == request_id)
|
||||
.order_by(Message.created_at.asc(), Message.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def serialize_message(row: Message) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"request_id": str(row.request_id),
|
||||
"author_type": row.author_type,
|
||||
"author_name": row.author_name,
|
||||
"body": row.body,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def create_client_message(db: Session, *, request: Request, body: str) -> Message:
|
||||
message_body = str(body or "").strip()
|
||||
if not message_body:
|
||||
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
|
||||
|
||||
row = Message(
|
||||
request_id=request.id,
|
||||
author_type="CLIENT",
|
||||
author_name=request.client_name,
|
||||
body=message_body,
|
||||
responsible="Клиент",
|
||||
)
|
||||
mark_unread_for_lawyer(request, EVENT_MESSAGE)
|
||||
request.responsible = "Клиент"
|
||||
notify_request_event(
|
||||
db,
|
||||
request=request,
|
||||
event_type=NOTIFICATION_EVENT_MESSAGE,
|
||||
actor_role="CLIENT",
|
||||
body=message_body,
|
||||
responsible="Клиент",
|
||||
)
|
||||
db.add(row)
|
||||
db.add(request)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def create_admin_or_lawyer_message(
|
||||
db: Session,
|
||||
*,
|
||||
request: Request,
|
||||
body: str,
|
||||
actor_role: str,
|
||||
actor_name: str,
|
||||
actor_admin_user_id: str | None = None,
|
||||
) -> Message:
|
||||
message_body = str(body or "").strip()
|
||||
if not message_body:
|
||||
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
|
||||
|
||||
normalized_role = str(actor_role or "").strip().upper()
|
||||
if normalized_role not in {"ADMIN", "LAWYER"}:
|
||||
raise HTTPException(status_code=400, detail="Некорректная роль автора сообщения")
|
||||
author_type = "LAWYER" if normalized_role == "LAWYER" else "SYSTEM"
|
||||
responsible = str(actor_name or "").strip() or "Администратор системы"
|
||||
|
||||
row = Message(
|
||||
request_id=request.id,
|
||||
author_type=author_type,
|
||||
author_name=str(actor_name or "").strip() or author_type,
|
||||
body=message_body,
|
||||
responsible=responsible,
|
||||
)
|
||||
mark_unread_for_client(request, EVENT_MESSAGE)
|
||||
request.responsible = responsible
|
||||
notify_request_event(
|
||||
db,
|
||||
request=request,
|
||||
event_type=NOTIFICATION_EVENT_MESSAGE,
|
||||
actor_role=normalized_role,
|
||||
actor_admin_user_id=actor_admin_user_id,
|
||||
body=message_body,
|
||||
responsible=responsible,
|
||||
)
|
||||
db.add(row)
|
||||
db.add(request)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
|
@ -90,8 +90,12 @@
|
|||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding-left: 0.6rem;
|
||||
padding-right: 0.2rem;
|
||||
border-left: 1px dashed rgba(212, 168, 106, 0.3);
|
||||
margin: 0.2rem 0 0.1rem 0.2rem;
|
||||
max-height: 38vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.menu-tree button {
|
||||
|
|
@ -210,6 +214,18 @@
|
|||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.breadcrumbs b {
|
||||
color: #dce7f7;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
|
@ -607,6 +623,380 @@
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
.simple-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.simple-list li {
|
||||
padding: 0.52rem 0.55rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.simple-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.request-modal {
|
||||
width: min(1120px, 100%);
|
||||
}
|
||||
|
||||
.request-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.request-workspace-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.request-workspace-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.9fr);
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.request-main-column {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.request-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.request-field {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 0.5rem 0.55rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.request-field-label {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9fb0c5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.request-field-value {
|
||||
font-size: 0.9rem;
|
||||
color: #d8e5f7;
|
||||
word-break: break-word;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.request-description-block,
|
||||
.request-extra-block {
|
||||
margin-top: 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 0.58rem;
|
||||
}
|
||||
|
||||
.request-description-block p {
|
||||
margin: 0.4rem 0 0;
|
||||
color: #d8e5f7;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.request-extra-list {
|
||||
margin-top: 0.45rem;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.request-status-route {
|
||||
margin-top: 0.85rem;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.request-status-route h4 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.request-route-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.route-item {
|
||||
position: relative;
|
||||
padding: 0 0 0.85rem 2.1rem;
|
||||
}
|
||||
|
||||
.route-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0.56rem;
|
||||
top: 0.52rem;
|
||||
bottom: -0.2rem;
|
||||
width: 2px;
|
||||
background: #5c6573;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.route-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.route-dot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.28rem;
|
||||
width: 1.12rem;
|
||||
height: 1.12rem;
|
||||
border-radius: 50%;
|
||||
background: #818999;
|
||||
border: 2px solid rgba(18, 30, 43, 0.95);
|
||||
box-shadow: 0 0 0 1px rgba(129, 137, 153, 0.35);
|
||||
}
|
||||
|
||||
.route-item.completed .route-dot,
|
||||
.route-item.current .route-dot {
|
||||
background: #3f72ff;
|
||||
box-shadow: 0 0 0 1px rgba(63, 114, 255, 0.35);
|
||||
}
|
||||
|
||||
.route-item.completed::before,
|
||||
.route-item.current::before {
|
||||
background: #3f72ff;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.route-body b {
|
||||
display: block;
|
||||
font-size: 1.02rem;
|
||||
color: #ebf2ff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.route-body p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #9fb0c5;
|
||||
line-height: 1.45;
|
||||
font-size: 0.9rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.route-time {
|
||||
margin-top: 0.22rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.request-modal-list {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.request-modal-item-meta {
|
||||
margin-top: 0.18rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.request-file-actions {
|
||||
margin-top: 0.45rem;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.request-file-link {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.request-attachments-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.request-upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.request-chat-block .request-modal-list {
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
.request-chat-list {
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
padding: 0.45rem 0.28rem 0.28rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
radial-gradient(ellipse at top left, rgba(82, 109, 156, 0.16), transparent 45%),
|
||||
radial-gradient(ellipse at bottom right, rgba(57, 86, 126, 0.12), transparent 45%),
|
||||
rgba(15, 23, 34, 0.72);
|
||||
}
|
||||
|
||||
.request-chat-list .chat-message {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
max-width: min(79%, 680px);
|
||||
}
|
||||
|
||||
.request-chat-list .chat-message.incoming {
|
||||
align-self: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.request-chat-list .chat-message.outgoing {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-message-author {
|
||||
font-size: 0.72rem;
|
||||
color: #9db3cf;
|
||||
line-height: 1.3;
|
||||
padding-inline: 0.3rem;
|
||||
}
|
||||
|
||||
.chat-message-bubble {
|
||||
border: 1px solid #44556f;
|
||||
border-radius: 15px 15px 15px 6px;
|
||||
background: linear-gradient(165deg, rgba(39, 52, 69, 0.94), rgba(24, 35, 49, 0.98));
|
||||
padding: 0.5rem 0.62rem 0.44rem;
|
||||
min-width: 120px;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.chat-message.outgoing .chat-message-bubble {
|
||||
border-color: #5f86d1;
|
||||
border-radius: 15px 15px 6px 15px;
|
||||
background: linear-gradient(165deg, rgba(63, 98, 169, 0.94), rgba(44, 73, 130, 0.98));
|
||||
}
|
||||
|
||||
.chat-message-text {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.43;
|
||||
color: #e6eef9;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-message-time {
|
||||
margin-top: 0.32rem;
|
||||
font-size: 0.74rem;
|
||||
color: #aab9cc;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chat-date-divider {
|
||||
margin: 0.32rem 0 0.24rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-date-divider span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.16rem 0.56rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(131, 151, 178, 0.34);
|
||||
background: rgba(46, 61, 84, 0.5);
|
||||
color: #b8c9df;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.request-preview-modal {
|
||||
width: min(980px, 100%);
|
||||
}
|
||||
|
||||
.request-preview-body {
|
||||
width: 100%;
|
||||
min-height: 280px;
|
||||
max-height: calc(92vh - 90px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #0f1722;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.request-preview-frame {
|
||||
width: 100%;
|
||||
height: min(72vh, 760px);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.request-preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 72vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.request-preview-video {
|
||||
width: min(100%, 860px);
|
||||
max-height: 72vh;
|
||||
}
|
||||
|
||||
.request-preview-note {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.request-preview-download {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -688,6 +1078,9 @@
|
|||
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.triple { grid-template-columns: 1fr; }
|
||||
.config-layout { grid-template-columns: 1fr; }
|
||||
.request-modal-grid { grid-template-columns: 1fr; }
|
||||
.request-workspace-layout { grid-template-columns: 1fr; }
|
||||
.request-card-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Административная панель • Правовой трекер</title>
|
||||
<link rel="stylesheet" href="/admin.css" integrity="sha384-ob5ClyWT89HFMlY1xFaLvCa0+FaL5KHhc//V2owTg+iFay2Lx0Y2U7fuGnRozMzD" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="/admin.css?v=20260225-1">
|
||||
</head>
|
||||
<body>
|
||||
<div id="admin-root"></div>
|
||||
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
|
||||
<script src="/admin.js"></script>
|
||||
<script src="/admin.js?v=20260225-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
397
app/web/client.css
Normal file
397
app/web/client.css
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
:root {
|
||||
--bg: #0d1217;
|
||||
--surface: #171f29;
|
||||
--surface-2: #1f2a37;
|
||||
--text: #f4f7fb;
|
||||
--muted: #a8b2c2;
|
||||
--accent: #d4a968;
|
||||
--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;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: min(var(--maxw), calc(100% - 1.5rem));
|
||||
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;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 0.84rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: #eef4ff;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: #d6deea;
|
||||
font-size: 0.93rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.client-shell {
|
||||
padding: 2rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: "Prata", serif;
|
||||
font-size: clamp(1.75rem, 4vw, 2.7rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 1.03rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cabinet-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.cabinet-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(160deg, rgba(23, 32, 42, 0.9), rgba(17, 24, 33, 0.95));
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.request-switcher {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.34rem;
|
||||
}
|
||||
|
||||
.field.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9fb0c6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #3b4b5f;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #ecf2fb;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
padding: 0.72rem 0.8rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 84px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
border-color: var(--line);
|
||||
color: #dde6f2;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.cabinet-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.58rem 0.65rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.meta-row small {
|
||||
display: block;
|
||||
color: #9fb0c6;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.meta-row b {
|
||||
display: block;
|
||||
color: #eaf2ff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.simple-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.simple-item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.58rem 0.65rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.simple-item p {
|
||||
margin: 0.24rem 0 0;
|
||||
color: #d8e3f3;
|
||||
line-height: 1.5;
|
||||
font-size: 0.92rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.simple-item time {
|
||||
color: #9eb1ca;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
margin-top: 0.45rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #f6d7a8;
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.32rem 0.68rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.file-link-btn:hover {
|
||||
border-color: rgba(212, 168, 106, 0.45);
|
||||
background: rgba(212, 168, 106, 0.14);
|
||||
}
|
||||
|
||||
.preview-body .file-link-btn {
|
||||
margin: 0.7rem;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(6, 10, 14, 0.82);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.preview-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.preview-modal {
|
||||
width: min(980px, 100%);
|
||||
max-height: 92vh;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(160deg, rgba(21, 31, 42, 0.96), rgba(14, 21, 28, 0.98));
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.preview-head h3 {
|
||||
margin: 0;
|
||||
font-family: "Prata", serif;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
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;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
width: 100%;
|
||||
min-height: 280px;
|
||||
max-height: calc(92vh - 76px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #0f1722;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
width: 100%;
|
||||
height: min(72vh, 760px);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 72vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
width: min(100%, 860px);
|
||||
max-height: 72vh;
|
||||
}
|
||||
|
||||
.preview-note {
|
||||
padding: 0.9rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-form {
|
||||
margin-top: 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
margin-top: 0.7rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 0.7rem 0 0;
|
||||
color: #9bafc8;
|
||||
font-size: 0.9rem;
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
|
||||
.status.ok { color: var(--ok); }
|
||||
.status.error { color: var(--danger); }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.cabinet-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cabinet-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.request-switcher {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.wrap {
|
||||
width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.cabinet-card {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
104
app/web/client.html
Normal file
104
app/web/client.html
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<!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="stylesheet" href="/client.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="wrap topbar-inner">
|
||||
<div class="brand">Кабинет клиента</div>
|
||||
<nav class="nav">
|
||||
<a href="/">На лендинг</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="wrap">
|
||||
<section class="client-shell">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h1>Работа с заявками</h1>
|
||||
<p class="subtitle">Выберите заявку, следите за статусом, перепиской, файлами и счетами.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="cabinet-card">
|
||||
<h2>Мои заявки</h2>
|
||||
<div class="request-switcher">
|
||||
<div class="field grow">
|
||||
<label for="client-request-select">Номер заявки</label>
|
||||
<select id="client-request-select"></select>
|
||||
</div>
|
||||
<button class="btn btn-ghost" id="client-refresh" type="button">Обновить</button>
|
||||
</div>
|
||||
<p class="status" id="client-page-status"></p>
|
||||
<div id="cabinet-summary" hidden>
|
||||
<div class="cabinet-meta">
|
||||
<div class="meta-row">
|
||||
<small>Статус</small>
|
||||
<b id="cabinet-request-status">-</b>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<small>Тема</small>
|
||||
<b id="cabinet-request-topic">-</b>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<small>Создана</small>
|
||||
<b id="cabinet-request-created">-</b>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<small>Обновлена</small>
|
||||
<b id="cabinet-request-updated">-</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="cabinet-layout">
|
||||
<article class="cabinet-card">
|
||||
<h2>Чат с юристом</h2>
|
||||
<ul class="simple-list" id="cabinet-messages"></ul>
|
||||
<form class="chat-form" id="cabinet-chat-form">
|
||||
<textarea id="cabinet-chat-body" placeholder="Введите сообщение" disabled></textarea>
|
||||
<button class="btn btn-ghost" type="submit" id="cabinet-chat-send" disabled>Отправить сообщение</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="cabinet-card">
|
||||
<h2>Файлы по заявке</h2>
|
||||
<ul class="simple-list" id="cabinet-files"></ul>
|
||||
<div class="file-row">
|
||||
<input id="cabinet-file-input" type="file" disabled>
|
||||
<button class="btn btn-ghost" id="cabinet-file-upload" type="button" disabled>Загрузить файл</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cabinet-card">
|
||||
<h2>Счета и оплата</h2>
|
||||
<ul class="simple-list" id="cabinet-invoices"></ul>
|
||||
</article>
|
||||
|
||||
<article class="cabinet-card">
|
||||
<h2>История изменений</h2>
|
||||
<ul class="simple-list" id="cabinet-timeline"></ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="preview-overlay" id="file-preview-overlay" aria-hidden="true">
|
||||
<div class="preview-modal" role="dialog" aria-modal="true" aria-labelledby="file-preview-title">
|
||||
<div class="preview-head">
|
||||
<h3 id="file-preview-title">Предпросмотр файла</h3>
|
||||
<button class="close-btn" id="file-preview-close" type="button" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
<div class="preview-body" id="file-preview-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
502
app/web/client.js
Normal file
502
app/web/client.js
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
(function () {
|
||||
const requestSelect = document.getElementById("client-request-select");
|
||||
const refreshButton = document.getElementById("client-refresh");
|
||||
const pageStatus = document.getElementById("client-page-status");
|
||||
|
||||
const cabinetSummary = document.getElementById("cabinet-summary");
|
||||
const cabinetRequestStatus = document.getElementById("cabinet-request-status");
|
||||
const cabinetRequestTopic = document.getElementById("cabinet-request-topic");
|
||||
const cabinetRequestCreated = document.getElementById("cabinet-request-created");
|
||||
const cabinetRequestUpdated = document.getElementById("cabinet-request-updated");
|
||||
|
||||
const cabinetMessages = document.getElementById("cabinet-messages");
|
||||
const cabinetFiles = document.getElementById("cabinet-files");
|
||||
const cabinetInvoices = document.getElementById("cabinet-invoices");
|
||||
const cabinetTimeline = document.getElementById("cabinet-timeline");
|
||||
|
||||
const cabinetChatForm = document.getElementById("cabinet-chat-form");
|
||||
const cabinetChatBody = document.getElementById("cabinet-chat-body");
|
||||
const cabinetChatSend = document.getElementById("cabinet-chat-send");
|
||||
const cabinetFileInput = document.getElementById("cabinet-file-input");
|
||||
const cabinetFileUpload = document.getElementById("cabinet-file-upload");
|
||||
const previewOverlay = document.getElementById("file-preview-overlay");
|
||||
const previewTitle = document.getElementById("file-preview-title");
|
||||
const previewClose = document.getElementById("file-preview-close");
|
||||
const previewBody = document.getElementById("file-preview-body");
|
||||
|
||||
let activeTrack = "";
|
||||
let activeRequestId = "";
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
try {
|
||||
const dt = new Date(value);
|
||||
if (Number.isNaN(dt.getTime())) return value;
|
||||
return dt.toLocaleString("ru-RU");
|
||||
} catch (_) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(el, message, kind) {
|
||||
el.className = "status";
|
||||
if (kind === "ok") el.classList.add("ok");
|
||||
if (kind === "error") el.classList.add("error");
|
||||
el.textContent = message;
|
||||
}
|
||||
|
||||
async function parseJsonSafe(response) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function apiErrorDetail(data, fallbackMessage) {
|
||||
if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail;
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
function setCabinetEnabled(enabled) {
|
||||
cabinetChatBody.disabled = !enabled;
|
||||
cabinetChatSend.disabled = !enabled;
|
||||
cabinetFileInput.disabled = !enabled;
|
||||
cabinetFileUpload.disabled = !enabled;
|
||||
requestSelect.disabled = !enabled;
|
||||
}
|
||||
|
||||
function clearList(node, emptyMessage) {
|
||||
node.innerHTML = "";
|
||||
const li = document.createElement("li");
|
||||
li.className = "simple-item";
|
||||
const p = document.createElement("p");
|
||||
p.textContent = emptyMessage;
|
||||
li.appendChild(p);
|
||||
node.appendChild(li);
|
||||
}
|
||||
|
||||
function detectPreviewKind(fileName, mimeType) {
|
||||
const name = String(fileName || "").toLowerCase();
|
||||
const mime = String(mimeType || "").toLowerCase();
|
||||
if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image";
|
||||
if (mime.startsWith("video/") || /\.(mp4|webm|ogg|mov|m4v)$/.test(name)) return "video";
|
||||
if (mime === "application/pdf" || /\.pdf$/.test(name)) return "pdf";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
if (!previewOverlay || !previewBody) return;
|
||||
previewOverlay.classList.remove("open");
|
||||
previewOverlay.setAttribute("aria-hidden", "true");
|
||||
previewBody.innerHTML = "";
|
||||
}
|
||||
|
||||
function openPreview(item) {
|
||||
if (!previewOverlay || !previewBody || !previewTitle || !item?.download_url) return;
|
||||
previewBody.innerHTML = "";
|
||||
previewTitle.textContent = item.file_name || "Предпросмотр файла";
|
||||
const kind = detectPreviewKind(item.file_name, item.mime_type);
|
||||
|
||||
if (kind === "image") {
|
||||
const img = document.createElement("img");
|
||||
img.className = "preview-image";
|
||||
img.src = item.download_url;
|
||||
img.alt = item.file_name || "Изображение";
|
||||
previewBody.appendChild(img);
|
||||
} else if (kind === "video") {
|
||||
const video = document.createElement("video");
|
||||
video.className = "preview-video";
|
||||
video.src = item.download_url;
|
||||
video.controls = true;
|
||||
video.preload = "metadata";
|
||||
previewBody.appendChild(video);
|
||||
} else if (kind === "pdf") {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "preview-frame";
|
||||
frame.src = item.download_url;
|
||||
frame.title = item.file_name || "PDF";
|
||||
previewBody.appendChild(frame);
|
||||
} else {
|
||||
const note = document.createElement("p");
|
||||
note.className = "preview-note";
|
||||
note.textContent = "Для этого типа файла доступно только открытие или скачивание.";
|
||||
previewBody.appendChild(note);
|
||||
}
|
||||
|
||||
const openLink = document.createElement("a");
|
||||
openLink.className = "file-link-btn";
|
||||
openLink.href = item.download_url;
|
||||
openLink.textContent = "Открыть / скачать";
|
||||
openLink.target = "_blank";
|
||||
openLink.rel = "noopener noreferrer";
|
||||
previewBody.appendChild(openLink);
|
||||
|
||||
previewOverlay.classList.add("open");
|
||||
previewOverlay.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function renderMessages(items) {
|
||||
cabinetMessages.innerHTML = "";
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
clearList(cabinetMessages, "Сообщений пока нет.");
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "simple-item";
|
||||
|
||||
const time = document.createElement("time");
|
||||
time.textContent = formatDate(item.created_at);
|
||||
li.appendChild(time);
|
||||
|
||||
const p = document.createElement("p");
|
||||
const author = item.author_name || item.author_type || "Участник";
|
||||
p.textContent = author + ": " + (item.body || "");
|
||||
li.appendChild(p);
|
||||
cabinetMessages.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFiles(items) {
|
||||
cabinetFiles.innerHTML = "";
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
clearList(cabinetFiles, "Файлы пока не загружены.");
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "simple-item";
|
||||
|
||||
const time = document.createElement("time");
|
||||
time.textContent = formatDate(item.created_at);
|
||||
li.appendChild(time);
|
||||
|
||||
const p = document.createElement("p");
|
||||
const sizeKb = Math.max(1, Math.round(Number(item.size_bytes || 0) / 1024));
|
||||
p.textContent = item.file_name + " (" + sizeKb + " КБ)";
|
||||
li.appendChild(p);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "file-actions";
|
||||
if (detectPreviewKind(item.file_name, item.mime_type) !== "none") {
|
||||
const previewBtn = document.createElement("button");
|
||||
previewBtn.type = "button";
|
||||
previewBtn.className = "file-link-btn";
|
||||
previewBtn.textContent = "Предпросмотр";
|
||||
previewBtn.addEventListener("click", () => openPreview(item));
|
||||
actions.appendChild(previewBtn);
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.className = "file-link-btn";
|
||||
link.href = item.download_url;
|
||||
link.textContent = "Открыть / скачать";
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
actions.appendChild(link);
|
||||
li.appendChild(actions);
|
||||
cabinetFiles.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderInvoices(items) {
|
||||
cabinetInvoices.innerHTML = "";
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
clearList(cabinetInvoices, "Счета пока не выставлены.");
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "simple-item";
|
||||
|
||||
const time = document.createElement("time");
|
||||
time.textContent = "Сформирован: " + formatDate(item.issued_at);
|
||||
li.appendChild(time);
|
||||
|
||||
const p = document.createElement("p");
|
||||
const amount = Number(item.amount || 0).toLocaleString("ru-RU");
|
||||
p.textContent =
|
||||
(item.invoice_number || "Счет") +
|
||||
" • " +
|
||||
(item.status_label || item.status || "-") +
|
||||
" • " +
|
||||
amount +
|
||||
" " +
|
||||
(item.currency || "RUB");
|
||||
li.appendChild(p);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = item.download_url;
|
||||
link.textContent = "Открыть / скачать PDF";
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.style.color = "#f6d7a8";
|
||||
li.appendChild(link);
|
||||
|
||||
cabinetInvoices.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTimeline(items) {
|
||||
cabinetTimeline.innerHTML = "";
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
clearList(cabinetTimeline, "История пока пуста.");
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "simple-item";
|
||||
|
||||
const time = document.createElement("time");
|
||||
time.textContent = formatDate(item.created_at);
|
||||
li.appendChild(time);
|
||||
|
||||
const p = document.createElement("p");
|
||||
if (item.type === "status_change") {
|
||||
p.textContent = "Статус: " + (item.payload?.from_status || "NEW") + " -> " + (item.payload?.to_status || "-");
|
||||
} else if (item.type === "message") {
|
||||
const author = item.payload?.author_name || item.payload?.author_type || "Участник";
|
||||
p.textContent = "Сообщение от " + author + ": " + (item.payload?.body || "");
|
||||
} else if (item.type === "attachment") {
|
||||
p.textContent = "Файл: " + (item.payload?.file_name || "вложение");
|
||||
} else {
|
||||
p.textContent = "Событие";
|
||||
}
|
||||
li.appendChild(p);
|
||||
cabinetTimeline.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchRequestByTrack(trackNumber) {
|
||||
const response = await fetch("/api/public/requests/" + encodeURIComponent(trackNumber));
|
||||
const data = await parseJsonSafe(response);
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
async function refreshCabinetData() {
|
||||
if (!activeTrack) return;
|
||||
|
||||
const [messagesRes, filesRes, invoicesRes, timelineRes] = await Promise.all([
|
||||
fetch("/api/public/chat/requests/" + encodeURIComponent(activeTrack) + "/messages"),
|
||||
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"),
|
||||
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/invoices"),
|
||||
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline"),
|
||||
]);
|
||||
|
||||
const messagesData = await parseJsonSafe(messagesRes);
|
||||
const filesData = await parseJsonSafe(filesRes);
|
||||
const invoicesData = await parseJsonSafe(invoicesRes);
|
||||
const timelineData = await parseJsonSafe(timelineRes);
|
||||
|
||||
if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения"));
|
||||
if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы"));
|
||||
if (!invoicesRes.ok) throw new Error(apiErrorDetail(invoicesData, "Не удалось загрузить счета"));
|
||||
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
|
||||
|
||||
renderMessages(messagesData);
|
||||
renderFiles(filesData);
|
||||
renderInvoices(invoicesData);
|
||||
renderTimeline(timelineData);
|
||||
}
|
||||
|
||||
function syncRequestSelector(rows, selectedTrack) {
|
||||
requestSelect.innerHTML = "";
|
||||
rows.forEach((row) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(row.track_number || "");
|
||||
option.textContent = String(row.track_number || "Без номера") + " • " + String(row.status_code || "-");
|
||||
requestSelect.appendChild(option);
|
||||
});
|
||||
if (selectedTrack) requestSelect.value = selectedTrack;
|
||||
}
|
||||
|
||||
async function openCabinetByTrack(trackNumber) {
|
||||
if (!trackNumber) return;
|
||||
try {
|
||||
setStatus(pageStatus, "Открываем заявку...", null);
|
||||
const { response, data } = await fetchRequestByTrack(trackNumber);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(apiErrorDetail(data, "Не удалось открыть заявку"));
|
||||
}
|
||||
|
||||
activeTrack = trackNumber;
|
||||
activeRequestId = data.id;
|
||||
cabinetRequestStatus.textContent = data.status_code || "-";
|
||||
cabinetRequestTopic.textContent = data.topic_code || "Не указана";
|
||||
cabinetRequestCreated.textContent = formatDate(data.created_at);
|
||||
cabinetRequestUpdated.textContent = formatDate(data.updated_at);
|
||||
cabinetSummary.hidden = false;
|
||||
setCabinetEnabled(true);
|
||||
|
||||
await refreshCabinetData();
|
||||
setStatus(pageStatus, "Открыта заявка: " + trackNumber, "ok");
|
||||
} catch (error) {
|
||||
setStatus(pageStatus, error?.message || "Не удалось открыть заявку", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyRequests(preferredTrack) {
|
||||
const response = await fetch("/api/public/requests/my");
|
||||
const data = await parseJsonSafe(response);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(apiErrorDetail(data, "Не удалось загрузить список заявок"));
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
||||
if (!rows.length) {
|
||||
requestSelect.innerHTML = "";
|
||||
cabinetSummary.hidden = true;
|
||||
setCabinetEnabled(false);
|
||||
setStatus(pageStatus, "По вашему номеру пока нет заявок.", null);
|
||||
clearList(cabinetMessages, "Сообщений пока нет.");
|
||||
clearList(cabinetFiles, "Файлы пока не загружены.");
|
||||
clearList(cabinetInvoices, "Счета пока не выставлены.");
|
||||
clearList(cabinetTimeline, "История пока пуста.");
|
||||
return;
|
||||
}
|
||||
|
||||
const tracks = rows.map((row) => String(row.track_number || "")).filter(Boolean);
|
||||
const selectedTrack = tracks.includes(preferredTrack) ? preferredTrack : tracks[0];
|
||||
syncRequestSelector(rows, selectedTrack);
|
||||
await openCabinetByTrack(selectedTrack);
|
||||
}
|
||||
|
||||
requestSelect.addEventListener("change", async () => {
|
||||
const track = String(requestSelect.value || "").trim();
|
||||
if (!track) return;
|
||||
await openCabinetByTrack(track);
|
||||
});
|
||||
|
||||
refreshButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await loadMyRequests(activeTrack || String(requestSelect.value || "").trim());
|
||||
} catch (error) {
|
||||
setStatus(pageStatus, error?.message || "Не удалось обновить список", "error");
|
||||
}
|
||||
});
|
||||
|
||||
if (previewClose) {
|
||||
previewClose.addEventListener("click", closePreview);
|
||||
}
|
||||
if (previewOverlay) {
|
||||
previewOverlay.addEventListener("click", (event) => {
|
||||
if (event.target === previewOverlay) closePreview();
|
||||
});
|
||||
}
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && previewOverlay?.classList.contains("open")) {
|
||||
closePreview();
|
||||
}
|
||||
});
|
||||
|
||||
cabinetChatForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeTrack) {
|
||||
setStatus(pageStatus, "Сначала выберите заявку.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = String(cabinetChatBody.value || "").trim();
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
setStatus(pageStatus, "Отправляем сообщение...", null);
|
||||
const response = await fetch("/api/public/chat/requests/" + encodeURIComponent(activeTrack) + "/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
const data = await parseJsonSafe(response);
|
||||
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить сообщение"));
|
||||
cabinetChatBody.value = "";
|
||||
await refreshCabinetData();
|
||||
setStatus(pageStatus, "Сообщение отправлено.", "ok");
|
||||
} catch (error) {
|
||||
setStatus(pageStatus, error?.message || "Ошибка отправки сообщения", "error");
|
||||
}
|
||||
});
|
||||
|
||||
cabinetFileUpload.addEventListener("click", async () => {
|
||||
if (!activeTrack || !activeRequestId) {
|
||||
setStatus(pageStatus, "Сначала выберите заявку.", "error");
|
||||
return;
|
||||
}
|
||||
const file = cabinetFileInput.files && cabinetFileInput.files[0];
|
||||
if (!file) {
|
||||
setStatus(pageStatus, "Выберите файл для загрузки.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus(pageStatus, "Подготавливаем загрузку файла...", null);
|
||||
const initResponse = await fetch("/api/public/uploads/init", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
file_name: file.name,
|
||||
mime_type: file.type || "application/octet-stream",
|
||||
size_bytes: file.size,
|
||||
scope: "REQUEST_ATTACHMENT",
|
||||
request_id: activeRequestId,
|
||||
}),
|
||||
});
|
||||
const initData = await parseJsonSafe(initResponse);
|
||||
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку"));
|
||||
|
||||
const putResponse = await fetch(initData.presigned_url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": file.type || "application/octet-stream" },
|
||||
body: file,
|
||||
});
|
||||
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
|
||||
|
||||
const completeResponse = await fetch("/api/public/uploads/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: initData.key,
|
||||
file_name: file.name,
|
||||
mime_type: file.type || "application/octet-stream",
|
||||
size_bytes: file.size,
|
||||
scope: "REQUEST_ATTACHMENT",
|
||||
request_id: activeRequestId,
|
||||
}),
|
||||
});
|
||||
const completeData = await parseJsonSafe(completeResponse);
|
||||
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку"));
|
||||
|
||||
cabinetFileInput.value = "";
|
||||
await refreshCabinetData();
|
||||
setStatus(pageStatus, "Файл загружен.", "ok");
|
||||
} catch (error) {
|
||||
setStatus(pageStatus, error?.message || "Ошибка загрузки файла", "error");
|
||||
}
|
||||
});
|
||||
|
||||
(async function bootstrap() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const preferredTrack = String(params.get("track") || "").trim().toUpperCase();
|
||||
|
||||
setCabinetEnabled(false);
|
||||
clearList(cabinetMessages, "Сообщений пока нет.");
|
||||
clearList(cabinetFiles, "Файлы пока не загружены.");
|
||||
clearList(cabinetInvoices, "Счета пока не выставлены.");
|
||||
clearList(cabinetTimeline, "История пока пуста.");
|
||||
|
||||
try {
|
||||
await loadMyRequests(preferredTrack);
|
||||
} catch (error) {
|
||||
setStatus(pageStatus, error?.message || "Не удалось открыть страницу клиента", "error");
|
||||
}
|
||||
})();
|
||||
})();
|
||||
|
|
@ -158,6 +158,8 @@
|
|||
padding: 1.3rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel::before {
|
||||
|
|
@ -215,6 +217,21 @@
|
|||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.consultation-quote {
|
||||
margin-top: auto;
|
||||
padding-top: 0.78rem;
|
||||
border-top: 1px dashed rgba(207, 217, 231, 0.24);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.consultation-quote p {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
color: #c6d4e8;
|
||||
min-height: 2.8rem;
|
||||
}
|
||||
|
||||
section { padding: 1.3rem 0 2.2rem; }
|
||||
|
||||
.section-head {
|
||||
|
|
@ -313,14 +330,25 @@
|
|||
.timeline h3 { margin: 0 0 0.35rem; font-size: 1rem; }
|
||||
.timeline p { margin: 0; color: var(--muted); line-height: 1.55; }
|
||||
|
||||
.quote {
|
||||
.approach-note {
|
||||
border: 1px solid #4b5b71;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(160deg, #1e2b3c, #1a2432);
|
||||
padding: 1rem;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.quote p {
|
||||
.approach-note small {
|
||||
display: block;
|
||||
margin-bottom: 0.52rem;
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #9cb2cb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.approach-note p {
|
||||
margin: 0;
|
||||
min-height: 5.3rem;
|
||||
line-height: 1.6;
|
||||
|
|
@ -478,7 +506,7 @@
|
|||
font-weight: 700;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #3b4b5f;
|
||||
|
|
@ -494,6 +522,15 @@
|
|||
resize: vertical;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: linear-gradient(45deg, transparent 50%, #cfd9e7 50%), linear-gradient(135deg, #cfd9e7 50%, transparent 50%);
|
||||
background-position: calc(100% - 18px) calc(1em + 2px), calc(100% - 12px) calc(1em + 2px);
|
||||
background-size: 6px 6px, 6px 6px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.form-foot {
|
||||
margin-top: 0.9rem;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Аудиторы корпоративной безопасности</title>
|
||||
<meta name="description" content="Юридический консалтинг и судебное сопровождение для сложных бизнес-ситуаций.">
|
||||
<link rel="stylesheet" href="/landing.css" integrity="sha384-f2MyL8409LTp2ap3kS1Yf2FMNVyeypb/qY1jl7WtZpImICXE/fpZCqZZT6keMp50" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="/landing.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
|
|
@ -15,8 +15,7 @@
|
|||
<a href="#practices">Компетенции</a>
|
||||
<a href="#approach">Подход</a>
|
||||
<a href="#expert">Эксперт</a>
|
||||
<a href="#cabinet">Кабинет клиента</a>
|
||||
<a href="/admin" class="btn btn-ghost">Админ-панель</a>
|
||||
<button class="btn btn-ghost" type="button" data-open-access>Мои заявки</button>
|
||||
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
@ -32,6 +31,7 @@
|
|||
</p>
|
||||
<div class="hero-actions">
|
||||
<button class="btn btn-primary" type="button" data-open-modal>Записаться на консультацию</button>
|
||||
<button class="btn btn-ghost" type="button" data-open-access>Работа с заявкой</button>
|
||||
<a class="btn btn-ghost" href="#practices">Смотреть практики</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -53,6 +53,10 @@
|
|||
<span>объем восстановленных прав</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="consultation-quote" aria-live="polite">
|
||||
<p id="quote-text">Загрузка данных...</p>
|
||||
<div class="quote-meta" id="quote-meta"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
|
|
@ -113,10 +117,9 @@
|
|||
<p>Сопровождаем исполнение решения, фиксируем сроки и контрольные точки, отчитываемся в понятном бизнес-формате.</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="quote">
|
||||
<small>Публичные цитаты</small>
|
||||
<p id="quote-text">Загрузка данных...</p>
|
||||
<div class="quote-meta" id="quote-meta"></div>
|
||||
<article class="approach-note">
|
||||
<small>Принцип работы</small>
|
||||
<p>Каждую заявку ведем как проект: фиксируем цель, измеряем прогресс и заранее обозначаем ограничения по срокам и рискам.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -144,81 +147,8 @@
|
|||
</article>
|
||||
</section>
|
||||
|
||||
<section id="cabinet">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Кабинет клиента</h2>
|
||||
<p class="subtitle">Введите номер заявки, подтвердите доступ по OTP и отслеживайте статус, переписку и файлы в одном окне.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cabinet-layout">
|
||||
<article class="cabinet-card">
|
||||
<h3>Доступ по номеру заявки</h3>
|
||||
<div class="field">
|
||||
<label for="cabinet-track">Номер заявки</label>
|
||||
<input id="cabinet-track" type="text" placeholder="TRK-XXXXXXXXXX">
|
||||
</div>
|
||||
<div class="form-foot">
|
||||
<button class="btn btn-primary" id="cabinet-open" type="button">Открыть кабинет</button>
|
||||
<p class="status" id="cabinet-status"></p>
|
||||
</div>
|
||||
<div id="cabinet-summary" hidden>
|
||||
<div class="cabinet-meta">
|
||||
<div class="meta-row">
|
||||
<small>Статус</small>
|
||||
<b id="cabinet-request-status">-</b>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<small>Тема</small>
|
||||
<b id="cabinet-request-topic">-</b>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<small>Создана</small>
|
||||
<b id="cabinet-request-created">-</b>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<small>Обновлена</small>
|
||||
<b id="cabinet-request-updated">-</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cabinet-card">
|
||||
<h3>Чат с юристом</h3>
|
||||
<ul class="simple-list" id="cabinet-messages"></ul>
|
||||
<form class="chat-form" id="cabinet-chat-form">
|
||||
<textarea id="cabinet-chat-body" placeholder="Введите сообщение" disabled></textarea>
|
||||
<button class="btn btn-ghost" type="submit" id="cabinet-chat-send" disabled>Отправить сообщение</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="cabinet-card">
|
||||
<h3>Файлы по заявке</h3>
|
||||
<ul class="simple-list" id="cabinet-files"></ul>
|
||||
<div class="file-row">
|
||||
<input id="cabinet-file-input" type="file" disabled>
|
||||
<button class="btn btn-ghost" id="cabinet-file-upload" type="button" disabled>Загрузить файл</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cabinet-card">
|
||||
<h3>Счета и оплата</h3>
|
||||
<ul class="simple-list" id="cabinet-invoices"></ul>
|
||||
</article>
|
||||
|
||||
<article class="cabinet-card">
|
||||
<h3>История изменений</h3>
|
||||
<ul class="simple-list" id="cabinet-timeline"></ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta-band">
|
||||
<p>
|
||||
Если вы пришли на сайт по рекомендации, укажите имя рекомендателя при отправке заявки.
|
||||
Это поможет быстрее подготовиться к консультации и учесть контекст вашей ситуации.
|
||||
</p>
|
||||
<p>Создайте заявку и получите номер обращения. По нему вы сможете отслеживать статус, чат и документы в отдельной странице клиента.</p>
|
||||
<button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button>
|
||||
</section>
|
||||
</main>
|
||||
|
|
@ -246,12 +176,14 @@
|
|||
<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>
|
||||
<label for="topic">Тема обращения</label>
|
||||
<select id="topic" name="topic" required>
|
||||
<option value="">Выберите тему</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="referral">Кто вас порекомендовал</label>
|
||||
<input id="referral" name="referral" type="text" placeholder="Имя рекомендателя">
|
||||
<label for="description">Описание задачи</label>
|
||||
<textarea id="description" name="description" placeholder="Кратко опишите ситуацию"></textarea>
|
||||
</div>
|
||||
<div class="form-foot field full">
|
||||
<button class="btn btn-primary" type="submit">Отправить заявку</button>
|
||||
|
|
@ -261,6 +193,33 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/landing.js" integrity="sha384-MfqV2MU/1TCgCqbg3o7JA2fTo0ZGtICVl5jvDOtDtS52TkjRf3kyrXnPpU8zaSPm" crossorigin="anonymous"></script>
|
||||
<div class="modal-backdrop" id="access-modal" aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="access-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h3 id="access-title">Вход в страницу заявок</h3>
|
||||
<p>Введите номер телефона, получите OTP и перейдите к своим заявкам.</p>
|
||||
</div>
|
||||
<button class="close" type="button" data-close-access aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
<form id="access-form" class="form">
|
||||
<div class="field full">
|
||||
<label for="access-phone">Телефон</label>
|
||||
<input id="access-phone" name="access-phone" type="tel" required placeholder="+7 (900) 000-00-00">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="access-code">Одноразовый пароль (OTP)</label>
|
||||
<input id="access-code" name="access-code" type="text" inputmode="numeric" pattern="[0-9]{4,8}" placeholder="Введите код из SMS">
|
||||
</div>
|
||||
<div class="form-foot field full">
|
||||
<button class="btn btn-ghost" type="button" id="access-send-otp">Получить одноразовый пароль</button>
|
||||
<button class="btn btn-primary" type="submit">Перейти</button>
|
||||
<p class="status" id="access-status"></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/landing.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,67 +1,26 @@
|
|||
(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 requestModal = document.getElementById("request-modal");
|
||||
const accessModal = document.getElementById("access-modal");
|
||||
const requestOpenButtons = document.querySelectorAll("[data-open-modal]");
|
||||
const requestCloseButtons = document.querySelectorAll("[data-close-modal]");
|
||||
const accessOpenButtons = document.querySelectorAll("[data-open-access]");
|
||||
const accessCloseButtons = document.querySelectorAll("[data-close-access]");
|
||||
|
||||
const requestForm = document.getElementById("request-form");
|
||||
const requestStatus = document.getElementById("form-status");
|
||||
const topicSelect = document.getElementById("topic");
|
||||
|
||||
const accessForm = document.getElementById("access-form");
|
||||
const accessPhoneInput = document.getElementById("access-phone");
|
||||
const accessCodeInput = document.getElementById("access-code");
|
||||
const accessSendOtpButton = document.getElementById("access-send-otp");
|
||||
const accessStatus = document.getElementById("access-status");
|
||||
|
||||
const quoteText = document.getElementById("quote-text");
|
||||
const quoteMeta = document.getElementById("quote-meta");
|
||||
const cabinetTrackInput = document.getElementById("cabinet-track");
|
||||
const cabinetOpenButton = document.getElementById("cabinet-open");
|
||||
const cabinetStatus = document.getElementById("cabinet-status");
|
||||
const cabinetSummary = document.getElementById("cabinet-summary");
|
||||
const cabinetRequestStatus = document.getElementById("cabinet-request-status");
|
||||
const cabinetRequestTopic = document.getElementById("cabinet-request-topic");
|
||||
const cabinetRequestCreated = document.getElementById("cabinet-request-created");
|
||||
const cabinetRequestUpdated = document.getElementById("cabinet-request-updated");
|
||||
const cabinetMessages = document.getElementById("cabinet-messages");
|
||||
const cabinetFiles = document.getElementById("cabinet-files");
|
||||
const cabinetInvoices = document.getElementById("cabinet-invoices");
|
||||
const cabinetTimeline = document.getElementById("cabinet-timeline");
|
||||
const cabinetChatForm = document.getElementById("cabinet-chat-form");
|
||||
const cabinetChatBody = document.getElementById("cabinet-chat-body");
|
||||
const cabinetChatSend = document.getElementById("cabinet-chat-send");
|
||||
const cabinetFileInput = document.getElementById("cabinet-file-input");
|
||||
const cabinetFileUpload = document.getElementById("cabinet-file-upload");
|
||||
|
||||
let activeTrack = "";
|
||||
let activeRequestId = "";
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
try {
|
||||
const dt = new Date(value);
|
||||
if (Number.isNaN(dt.getTime())) return value;
|
||||
return dt.toLocaleString("ru-RU");
|
||||
} catch (_) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(el, message, kind) {
|
||||
if (!el) return;
|
||||
el.className = "status";
|
||||
if (kind === "ok") el.classList.add("ok");
|
||||
if (kind === "error") el.classList.add("error");
|
||||
|
|
@ -81,140 +40,77 @@
|
|||
return fallbackMessage;
|
||||
}
|
||||
|
||||
function setCabinetEnabled(enabled) {
|
||||
cabinetChatBody.disabled = !enabled;
|
||||
cabinetChatSend.disabled = !enabled;
|
||||
cabinetFileInput.disabled = !enabled;
|
||||
cabinetFileUpload.disabled = !enabled;
|
||||
function openModal(modal) {
|
||||
if (!modal) return;
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
document.body.classList.add("modal-open");
|
||||
}
|
||||
|
||||
function clearList(node, emptyMessage) {
|
||||
node.innerHTML = "";
|
||||
const li = document.createElement("li");
|
||||
li.className = "simple-item";
|
||||
const p = document.createElement("p");
|
||||
p.textContent = emptyMessage;
|
||||
li.appendChild(p);
|
||||
node.appendChild(li);
|
||||
function closeModal(modal) {
|
||||
if (!modal) return;
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
if (!document.querySelector(".modal-backdrop.open")) {
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
}
|
||||
|
||||
function renderMessages(items) {
|
||||
cabinetMessages.innerHTML = "";
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
clearList(cabinetMessages, "Сообщений пока нет.");
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "simple-item";
|
||||
|
||||
const time = document.createElement("time");
|
||||
time.textContent = formatDate(item.created_at);
|
||||
li.appendChild(time);
|
||||
|
||||
const p = document.createElement("p");
|
||||
const author = item.author_name || item.author_type || "Участник";
|
||||
p.textContent = author + ": " + (item.body || "");
|
||||
li.appendChild(p);
|
||||
cabinetMessages.appendChild(li);
|
||||
requestOpenButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => openModal(requestModal));
|
||||
});
|
||||
}
|
||||
|
||||
function renderFiles(items) {
|
||||
cabinetFiles.innerHTML = "";
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
clearList(cabinetFiles, "Файлы пока не загружены.");
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "simple-item";
|
||||
|
||||
const time = document.createElement("time");
|
||||
time.textContent = formatDate(item.created_at);
|
||||
li.appendChild(time);
|
||||
|
||||
const p = document.createElement("p");
|
||||
const sizeKb = Math.max(1, Math.round(Number(item.size_bytes || 0) / 1024));
|
||||
p.textContent = item.file_name + " (" + sizeKb + " КБ)";
|
||||
li.appendChild(p);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = item.download_url;
|
||||
link.textContent = "Открыть / скачать";
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.style.color = "#f6d7a8";
|
||||
li.appendChild(link);
|
||||
cabinetFiles.appendChild(li);
|
||||
requestCloseButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => closeModal(requestModal));
|
||||
});
|
||||
}
|
||||
|
||||
function renderInvoices(items) {
|
||||
cabinetInvoices.innerHTML = "";
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
clearList(cabinetInvoices, "Счета пока не выставлены.");
|
||||
accessOpenButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
const response = await fetch("/api/public/requests/my");
|
||||
if (response.ok) {
|
||||
window.location.href = "/client.html";
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "simple-item";
|
||||
|
||||
const time = document.createElement("time");
|
||||
time.textContent = "Сформирован: " + formatDate(item.issued_at);
|
||||
li.appendChild(time);
|
||||
|
||||
const p = document.createElement("p");
|
||||
const amount = Number(item.amount || 0).toLocaleString("ru-RU");
|
||||
p.textContent =
|
||||
(item.invoice_number || "Счет") +
|
||||
" • " +
|
||||
(item.status_label || item.status || "-") +
|
||||
" • " +
|
||||
amount +
|
||||
" " +
|
||||
(item.currency || "RUB");
|
||||
li.appendChild(p);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = item.download_url;
|
||||
link.textContent = "Открыть / скачать PDF";
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.style.color = "#f6d7a8";
|
||||
li.appendChild(link);
|
||||
|
||||
cabinetInvoices.appendChild(li);
|
||||
} catch (_) {}
|
||||
setStatus(accessStatus, "", null);
|
||||
openModal(accessModal);
|
||||
});
|
||||
});
|
||||
accessCloseButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => closeModal(accessModal));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTimeline(items) {
|
||||
cabinetTimeline.innerHTML = "";
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
clearList(cabinetTimeline, "История пока пуста.");
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "simple-item";
|
||||
[requestModal, accessModal].forEach((modal) => {
|
||||
if (!modal) return;
|
||||
modal.addEventListener("click", (event) => {
|
||||
if (event.target === modal) closeModal(modal);
|
||||
});
|
||||
});
|
||||
|
||||
const time = document.createElement("time");
|
||||
time.textContent = formatDate(item.created_at);
|
||||
li.appendChild(time);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
closeModal(requestModal);
|
||||
closeModal(accessModal);
|
||||
});
|
||||
|
||||
const p = document.createElement("p");
|
||||
if (item.type === "status_change") {
|
||||
p.textContent = "Статус: " + (item.payload?.from_status || "NEW") + " -> " + (item.payload?.to_status || "-");
|
||||
} else if (item.type === "message") {
|
||||
const author = item.payload?.author_name || item.payload?.author_type || "Участник";
|
||||
p.textContent = "Сообщение от " + author + ": " + (item.payload?.body || "");
|
||||
} else if (item.type === "attachment") {
|
||||
p.textContent = "Файл: " + (item.payload?.file_name || "вложение");
|
||||
} else {
|
||||
p.textContent = "Событие";
|
||||
async function loadTopics() {
|
||||
if (!topicSelect) return;
|
||||
const fallback = [{ code: "consulting", name: "Консультация" }];
|
||||
let topics = fallback;
|
||||
try {
|
||||
const response = await fetch("/api/public/requests/topics");
|
||||
const data = await parseJsonSafe(response);
|
||||
if (response.ok && Array.isArray(data) && data.length > 0) {
|
||||
topics = data;
|
||||
}
|
||||
li.appendChild(p);
|
||||
cabinetTimeline.appendChild(li);
|
||||
} catch (_) {}
|
||||
|
||||
topicSelect.innerHTML = '<option value="">Выберите тему</option>';
|
||||
topics.forEach((row) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(row.code || "");
|
||||
option.textContent = String(row.name || row.code || "Тема");
|
||||
topicSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -233,271 +129,129 @@
|
|||
};
|
||||
render();
|
||||
if (items.length > 1) setInterval(render, 5500);
|
||||
} catch (error) {
|
||||
} catch (_) {
|
||||
quoteText.textContent = "С вами работает дружный коллектив профессионалов. Мы уверены в вашем успехе.";
|
||||
quoteMeta.textContent = "Команда компании";
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRequestByTrack(trackNumber) {
|
||||
const response = await fetch("/api/public/requests/" + encodeURIComponent(trackNumber));
|
||||
const data = await parseJsonSafe(response);
|
||||
return { response, data };
|
||||
accessSendOtpButton.addEventListener("click", async () => {
|
||||
const phone = String(accessPhoneInput.value || "").trim();
|
||||
if (!phone) {
|
||||
setStatus(accessStatus, "Введите номер телефона.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
async function ensureViewAccess(trackNumber) {
|
||||
let { response, data } = await fetchRequestByTrack(trackNumber);
|
||||
if (response.ok) return data;
|
||||
|
||||
if (response.status !== 401 && response.status !== 403) {
|
||||
throw new Error(apiErrorDetail(data, "Не удалось открыть заявку"));
|
||||
}
|
||||
|
||||
setStatus(cabinetStatus, "Отправляем OTP-код...", null);
|
||||
const sendResponse = await fetch("/api/public/otp/send", {
|
||||
try {
|
||||
setStatus(accessStatus, "Отправляем OTP-код...", null);
|
||||
const response = await fetch("/api/public/otp/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
purpose: "VIEW_REQUEST",
|
||||
track_number: trackNumber
|
||||
})
|
||||
client_phone: phone,
|
||||
}),
|
||||
});
|
||||
const sendData = await parseJsonSafe(sendResponse);
|
||||
if (!sendResponse.ok) {
|
||||
throw new Error(apiErrorDetail(sendData, "Не удалось отправить OTP"));
|
||||
const data = await parseJsonSafe(response);
|
||||
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить OTP"));
|
||||
setStatus(accessStatus, "Код отправлен. Проверьте SMS.", "ok");
|
||||
} catch (error) {
|
||||
setStatus(accessStatus, error?.message || "Не удалось отправить OTP", "error");
|
||||
}
|
||||
});
|
||||
|
||||
accessForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const phone = String(accessPhoneInput.value || "").trim();
|
||||
const code = String(accessCodeInput.value || "").trim();
|
||||
if (!phone || !code) {
|
||||
setStatus(accessStatus, "Введите телефон и OTP-код.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
|
||||
if (!code) {
|
||||
throw new Error("Код OTP не введен");
|
||||
}
|
||||
|
||||
setStatus(cabinetStatus, "Проверяем OTP...", null);
|
||||
const verifyResponse = await fetch("/api/public/otp/verify", {
|
||||
try {
|
||||
setStatus(accessStatus, "Проверяем OTP...", null);
|
||||
const response = await fetch("/api/public/otp/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
purpose: "VIEW_REQUEST",
|
||||
track_number: trackNumber,
|
||||
code: String(code).trim()
|
||||
})
|
||||
});
|
||||
const verifyData = await parseJsonSafe(verifyResponse);
|
||||
if (!verifyResponse.ok) {
|
||||
throw new Error(apiErrorDetail(verifyData, "OTP не подтвержден"));
|
||||
}
|
||||
|
||||
({ response, data } = await fetchRequestByTrack(trackNumber));
|
||||
if (!response.ok) {
|
||||
throw new Error(apiErrorDetail(data, "Нет доступа к заявке"));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function refreshCabinetData() {
|
||||
if (!activeTrack) return;
|
||||
|
||||
const [messagesRes, filesRes, invoicesRes, timelineRes] = await Promise.all([
|
||||
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"),
|
||||
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"),
|
||||
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/invoices"),
|
||||
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline")
|
||||
]);
|
||||
|
||||
const messagesData = await parseJsonSafe(messagesRes);
|
||||
const filesData = await parseJsonSafe(filesRes);
|
||||
const invoicesData = await parseJsonSafe(invoicesRes);
|
||||
const timelineData = await parseJsonSafe(timelineRes);
|
||||
|
||||
if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения"));
|
||||
if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы"));
|
||||
if (!invoicesRes.ok) throw new Error(apiErrorDetail(invoicesData, "Не удалось загрузить счета"));
|
||||
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
|
||||
|
||||
renderMessages(messagesData);
|
||||
renderFiles(filesData);
|
||||
renderInvoices(invoicesData);
|
||||
renderTimeline(timelineData);
|
||||
}
|
||||
|
||||
async function openCabinetByTrack() {
|
||||
const trackNumber = String(cabinetTrackInput.value || "").trim().toUpperCase();
|
||||
if (!trackNumber) {
|
||||
setStatus(cabinetStatus, "Введите номер заявки.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus(cabinetStatus, "Открываем кабинет...", null);
|
||||
const requestData = await ensureViewAccess(trackNumber);
|
||||
activeTrack = trackNumber;
|
||||
activeRequestId = requestData.id;
|
||||
|
||||
cabinetRequestStatus.textContent = requestData.status_code || "-";
|
||||
cabinetRequestTopic.textContent = requestData.topic_code || "Не указана";
|
||||
cabinetRequestCreated.textContent = formatDate(requestData.created_at);
|
||||
cabinetRequestUpdated.textContent = formatDate(requestData.updated_at);
|
||||
cabinetSummary.hidden = false;
|
||||
setCabinetEnabled(true);
|
||||
|
||||
await refreshCabinetData();
|
||||
setStatus(cabinetStatus, "Кабинет открыт: " + trackNumber, "ok");
|
||||
} catch (error) {
|
||||
setStatus(cabinetStatus, error?.message || "Не удалось открыть кабинет", "error");
|
||||
}
|
||||
}
|
||||
|
||||
cabinetOpenButton.addEventListener("click", () => {
|
||||
openCabinetByTrack();
|
||||
});
|
||||
|
||||
cabinetChatForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeTrack) {
|
||||
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = String(cabinetChatBody.value || "").trim();
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
setStatus(cabinetStatus, "Отправляем сообщение...", null);
|
||||
const response = await fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body })
|
||||
client_phone: phone,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
const data = await parseJsonSafe(response);
|
||||
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить сообщение"));
|
||||
cabinetChatBody.value = "";
|
||||
await refreshCabinetData();
|
||||
setStatus(cabinetStatus, "Сообщение отправлено.", "ok");
|
||||
if (!response.ok) throw new Error(apiErrorDetail(data, "OTP не подтвержден"));
|
||||
setStatus(accessStatus, "Доступ подтвержден. Переходим...", "ok");
|
||||
window.location.href = "/client.html";
|
||||
} catch (error) {
|
||||
setStatus(cabinetStatus, error?.message || "Ошибка отправки сообщения", "error");
|
||||
setStatus(accessStatus, error?.message || "Ошибка проверки OTP", "error");
|
||||
}
|
||||
});
|
||||
|
||||
cabinetFileUpload.addEventListener("click", async () => {
|
||||
if (!activeTrack || !activeRequestId) {
|
||||
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error");
|
||||
return;
|
||||
}
|
||||
const file = cabinetFileInput.files && cabinetFileInput.files[0];
|
||||
if (!file) {
|
||||
setStatus(cabinetStatus, "Выберите файл для загрузки.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus(cabinetStatus, "Подготавливаем загрузку файла...", null);
|
||||
const initResponse = await fetch("/api/public/uploads/init", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
file_name: file.name,
|
||||
mime_type: file.type || "application/octet-stream",
|
||||
size_bytes: file.size,
|
||||
scope: "REQUEST_ATTACHMENT",
|
||||
request_id: activeRequestId
|
||||
})
|
||||
});
|
||||
const initData = await parseJsonSafe(initResponse);
|
||||
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку"));
|
||||
|
||||
const putResponse = await fetch(initData.presigned_url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": file.type || "application/octet-stream" },
|
||||
body: file
|
||||
});
|
||||
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
|
||||
|
||||
const completeResponse = await fetch("/api/public/uploads/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: initData.key,
|
||||
file_name: file.name,
|
||||
mime_type: file.type || "application/octet-stream",
|
||||
size_bytes: file.size,
|
||||
scope: "REQUEST_ATTACHMENT",
|
||||
request_id: activeRequestId
|
||||
})
|
||||
});
|
||||
const completeData = await parseJsonSafe(completeResponse);
|
||||
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку"));
|
||||
|
||||
cabinetFileInput.value = "";
|
||||
await refreshCabinetData();
|
||||
setStatus(cabinetStatus, "Файл загружен.", "ok");
|
||||
} catch (error) {
|
||||
setStatus(cabinetStatus, error?.message || "Ошибка загрузки файла", "error");
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
requestForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
setStatus(status, "Отправляем заявку...", null);
|
||||
setStatus(requestStatus, "Отправляем заявку...", null);
|
||||
|
||||
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()
|
||||
}
|
||||
client_name: String(document.getElementById("name").value || "").trim(),
|
||||
client_phone: String(document.getElementById("phone").value || "").trim(),
|
||||
topic_code: String(document.getElementById("topic").value || "").trim(),
|
||||
description: String(document.getElementById("description").value || "").trim(),
|
||||
extra_fields: {},
|
||||
};
|
||||
|
||||
if (!payload.client_name || !payload.client_phone || !payload.topic_code) {
|
||||
setStatus(requestStatus, "Заполните имя, телефон и тему обращения.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus(status, "Отправляем OTP-код...", null);
|
||||
setStatus(requestStatus, "Отправляем OTP-код...", null);
|
||||
const otpSend = await fetch("/api/public/otp/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
purpose: "CREATE_REQUEST",
|
||||
client_phone: payload.client_phone
|
||||
})
|
||||
client_phone: payload.client_phone,
|
||||
}),
|
||||
});
|
||||
if (!otpSend.ok) throw new Error("otp send failed");
|
||||
const otpSendData = await parseJsonSafe(otpSend);
|
||||
if (!otpSend.ok) throw new Error(apiErrorDetail(otpSendData, "Не удалось отправить OTP"));
|
||||
|
||||
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
|
||||
if (!code) throw new Error("otp code required");
|
||||
if (!code) throw new Error("Код OTP не введен");
|
||||
|
||||
setStatus(status, "Проверяем OTP...", null);
|
||||
setStatus(requestStatus, "Проверяем OTP...", null);
|
||||
const otpVerify = await fetch("/api/public/otp/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
purpose: "CREATE_REQUEST",
|
||||
client_phone: payload.client_phone,
|
||||
code: String(code).trim()
|
||||
})
|
||||
code: String(code).trim(),
|
||||
}),
|
||||
});
|
||||
if (!otpVerify.ok) throw new Error("otp verify failed");
|
||||
const otpVerifyData = await parseJsonSafe(otpVerify);
|
||||
if (!otpVerify.ok) throw new Error(apiErrorDetail(otpVerifyData, "OTP не подтвержден"));
|
||||
|
||||
setStatus(status, "Создаем заявку...", null);
|
||||
setStatus(requestStatus, "Создаем заявку...", null);
|
||||
const response = await fetch("/api/public/requests", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await parseJsonSafe(response);
|
||||
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось создать заявку"));
|
||||
|
||||
if (!response.ok) throw new Error("create request failed");
|
||||
const data = await response.json();
|
||||
setStatus(status, "Заявка принята. Номер: " + data.track_number, "ok");
|
||||
cabinetTrackInput.value = data.track_number;
|
||||
form.reset();
|
||||
setTimeout(closeModal, 1200);
|
||||
setStatus(requestStatus, "Заявка принята. Номер: " + data.track_number, "ok");
|
||||
requestForm.reset();
|
||||
setTimeout(() => closeModal(requestModal), 1200);
|
||||
} catch (error) {
|
||||
setStatus(status, "Не удалось отправить заявку. Повторите попытку позже.", "error");
|
||||
setStatus(requestStatus, error?.message || "Не удалось отправить заявку. Повторите попытку позже.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
loadTopics();
|
||||
loadQuotes();
|
||||
setCabinetEnabled(false);
|
||||
clearList(cabinetMessages, "Сообщений пока нет.");
|
||||
clearList(cabinetFiles, "Файлы пока не загружены.");
|
||||
clearList(cabinetInvoices, "Счета пока не выставлены.");
|
||||
clearList(cabinetTimeline, "История пока пуста.");
|
||||
})();
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -46,6 +46,18 @@
|
|||
| P25 | сделано | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) |
|
||||
| P26 | сделано | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов |
|
||||
| P27 | сделано | Итоговое тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers и выполнить финальный регрессионный прогон | Набор автотестов фиксирует регрессии критичных сценариев и подтверждает готовность перед приемкой |
|
||||
| P28 | сделано | Справочники: все таблицы БД | Обеспечить отображение в «Справочниках» всех таблиц БД (кроме выделенных разделов «Заявки» и «Счета»), включая `clients` при наличии отдельной таблицы клиентов | Создана таблица `clients` через миграцию, добавлены ссылки в `requests`/`invoices`, справочник `clients` доступен через universal meta |
|
||||
| P29 | сделано | Модальная форма заявки | Оставить создание заявки только в модальном окне; убрать дублирующие/вводящие в заблуждение блоки с формы и лендинга, вернуть явный выбор темы обращения | На лендинге модалка создания заявки содержит выбор темы; блок и поле рекомендаций удалены |
|
||||
| P30 | сделано | Отдельная страница клиента | Вынести работу с заявкой клиента (статус, чат, файлы, счета, таймлайн) на отдельную страницу, а не в нижний блок лендинга | Реализована отдельная страница `client.html` с полным рабочим контуром заявки |
|
||||
| P31 | сделано | Вход клиента через OTP-модалку | Добавить на лендинг кнопку перехода в страницу работы с заявкой через модалку авторизации: телефон + OTP (если нет валидной 7-дневной cookie/JWT) | На лендинге добавлена OTP-модалка входа по телефону; при валидной сессии переход в клиентскую страницу выполняется сразу |
|
||||
| P32 | сделано | Переключение между заявками клиента | Спроектировать и реализовать переключение между несколькими заявками авторизованного клиента на странице работы с заявкой | На странице клиента добавлен селектор «Мои заявки» и серверный endpoint `/api/public/requests/my` |
|
||||
| P33 | сделано | Выделенный сервис чата | Выделить чат (клиент↔юрист) в отдельный сервис/контур с API-границей, сохранив текущие бизнес-правила, RBAC и read/unread поведение | Добавлен отдельный сервисный слой `chat_service` и отдельные API-контуры `/api/public/chat/*`, `/api/admin/chat/*`; UI и тесты переведены и проходят |
|
||||
| P34 | сделано | UX цитат в «Первая консультация» | Перенести цитаты в ненавязчивый формат внутри блока «Первая консультация», убрать визуальный шум и конкуренцию с основным CTA | Цитаты перенесены в блок «Первая консультация» в hero-панели как компактный элемент без конкуренции с CTA |
|
||||
| P35 | сделано | Предпросмотр документов | Добавить предпросмотр загруженных документов (pdf/jpg/mp4) в модальном окне или выделенной зоне страницы заявки, не ломая текущую загрузку/скачивание | Предпросмотр реализован в `client.html` и в рабочей вкладке заявки `admin.jsx` (`/admin.html?view=request&requestId=...`); сохранено действие «Открыть / скачать», добавлен backend тест inline-preview |
|
||||
| P36 | сделано | Навигация в админ-панель | Убрать кнопку «Админ-панель» с лендинга, исправить редиректы/роутинг (`/admin`, `/admin.html`) чтобы не было перехода на неверный host/port | Кнопка админки удалена с лендинга; `/admin` корректно переводит на `/admin.html`; добавлен e2e smoke `admin_entry_flow` |
|
||||
| P37 | сделано | Админ-авторизация и креды | Привести к единому правилу bootstrap-креды администратора (`admin@example.com` + согласованный пароль), обновить документацию/контекст и smoke-проверки логина | Реализован bootstrap-login с автосозданием администратора `admin@example.com` / `admin123`; добавлены автотесты `tests/test_admin_auth.py` |
|
||||
| P38 | к разработке | Конструктор маршрутов статусов | Реализовать для администратора визуальный конструктор маршрутов статусов по каждой теме: вариативные переходы (в т.ч. возврат на предыдущий статус, переход в завершение и альтернативные ветки), SLA на переход, список обязательных документов/данных для закрытия шага | Админ может собрать/изменить граф переходов для темы, задать SLA и требования на каждом шаге; API валидирует переходы и требования, UI отображает и редактирует граф без ручного JSON |
|
||||
| P39 | к разработке | Канбан по заявкам (LAWYER/ADMIN) | Реализовать канбан-доску заявок с унификацией разных статусных флоу через группы колонок (например: `Новые`, `В работе`, `Ожидание`, `Завершены`) + карточки заявок с ключевыми данными (дата создания, клиент, описание, новые сообщения/файлы, SLA deadline/дедлайн дела) | Для `LAWYER` видны свои + неназначенные заявки, для `ADMIN` — все юристы и заявки; карточки перетаскиваются/переводятся между допустимыми этапами с серверной валидацией |
|
||||
|
||||
## Критический маршрут (обязательный порядок)
|
||||
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
|
||||
|
|
@ -53,6 +65,33 @@
|
|||
3. `P14 -> P15 -> P16` (процесс работы по заявке).
|
||||
4. `P17 -> P18 -> P24 -> P25 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика).
|
||||
5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце).
|
||||
6. `P28 -> P29 -> P30 -> P31 -> P32 -> P33 -> P35 -> P34 -> P36 -> P37` (итерация UX/клиентского входа/чат-сервиса/навигации/доступов).
|
||||
7. `P38 -> P39` (конструктор маршрутов и канбан-представление заявок для ролей).
|
||||
|
||||
## Детализация P38-P39 (новый контур)
|
||||
### P38. Конструктор маршрутов статусов
|
||||
1. Граф переходов по теме:
|
||||
хранение `from_status -> to_status`, признак `enabled`, `sort_order`, `sla_hours`.
|
||||
2. Вариативные переходы:
|
||||
поддержать переходы назад, терминальные выходы и параллельные ветки.
|
||||
3. Требования на шаг:
|
||||
для каждого целевого статуса хранить список обязательных документов/данных для закрытия этапа.
|
||||
4. Валидация:
|
||||
серверно проверять допустимость перехода и наличие обязательных данных перед сменой статуса.
|
||||
5. UI конструктора:
|
||||
визуальный редактор узлов/связей + форма параметров шага (SLA, требования, terminal).
|
||||
|
||||
### P39. Канбан заявок
|
||||
1. Унификация статусов:
|
||||
ввести группировку статусов в канбан-колонки (`НОВЫЕ`, `В РАБОТЕ`, `ОЖИДАНИЕ`, `ЗАВЕРШЕНЫ`).
|
||||
2. Маппинг:
|
||||
каждый статус темы привязать к одной канбан-группе (конфигурируемо администратором).
|
||||
3. Ролевой scope:
|
||||
`LAWYER` видит свои + неназначенные заявки, `ADMIN` — все заявки всех юристов.
|
||||
4. Карточка канбана:
|
||||
`track_number`, дата создания, клиент, краткое описание, индикаторы новых сообщений/файлов, SLA-дедлайн/дата дела.
|
||||
5. Действия:
|
||||
перевод карточки между колонками только по допустимым серверным переходам.
|
||||
|
||||
## Правила выполнения для ИИ-агента
|
||||
1. Не менять бизнес-правила без обновления `context/*.md`.
|
||||
|
|
@ -62,3 +101,4 @@
|
|||
5. Для операций назначения использовать транзакционную защиту от гонок.
|
||||
6. Для статусов и SLA использовать только серверную валидацию (не доверять фронту).
|
||||
7. Перед переводом пункта в `сделано` выполнять проверки из `context/11_test_runbook.md`.
|
||||
8. UI e2e запускать через фиксированный compose-сервис `e2e` (образ `law-e2e-playwright:1.58.2`) для стабильного повторяемого прогона без повторной загрузки браузеров.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Runbook Проверок (Тесты и Валидация по Плану)
|
||||
|
||||
## Назначение
|
||||
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P27` и как их запускать.
|
||||
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P39` и как их запускать.
|
||||
Использовать перед переводом пункта в статус `сделано`.
|
||||
|
||||
## Базовые команды
|
||||
|
|
@ -22,10 +22,19 @@ docker compose exec -T backend python -m compileall app tests alembic
|
|||
docker compose build frontend
|
||||
docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js"
|
||||
```
|
||||
5. Браузерный E2E (Playwright) для ролевых UI-флоу (PUBLIC / LAWYER / ADMIN):
|
||||
5. Браузерный E2E (Playwright) для ролевых UI-флоу (PUBLIC / LAWYER / ADMIN) через фиксированный образ `law-e2e-playwright:1.58.2`:
|
||||
```bash
|
||||
docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft.com/playwright:v1.58.2-jammy sh -lc "npm install --silent && E2E_BASE_URL=http://frontend E2E_ADMIN_EMAIL=admin@example.com E2E_ADMIN_PASSWORD='AdminPass-123!' E2E_LAWYER_EMAIL=ivan@mail.ru E2E_LAWYER_PASSWORD='LawyerPass-123!' npx playwright test --config=playwright.config.js"
|
||||
docker compose build e2e
|
||||
docker compose run --rm --no-deps e2e playwright --version
|
||||
docker compose run --rm --no-deps \
|
||||
-e E2E_BASE_URL=http://frontend \
|
||||
-e E2E_ADMIN_EMAIL=admin@example.com \
|
||||
-e E2E_ADMIN_PASSWORD='admin123' \
|
||||
-e E2E_LAWYER_EMAIL=ivan@mail.ru \
|
||||
-e E2E_LAWYER_PASSWORD='LawyerPass-123!' \
|
||||
e2e playwright test --config=playwright.config.js
|
||||
```
|
||||
Примечание: образ `e2e` собирается один раз и переиспользуется, браузеры/Playwright не скачиваются при каждом запуске.
|
||||
|
||||
## Матрица проверок по задачам
|
||||
| ID | Что проверяем | Где тесты | Как запускать |
|
||||
|
|
@ -56,7 +65,19 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
|
|||
| P24 | Ставки юриста и ставка заявки | `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_rates tests.test_admin_universal_crud -v`; проверка что public API не отдает поля ставок/процентов |
|
||||
| P25 | Billing-статус и шаблон счета | `tests/test_billing_flow.py`, `tests/test_invoices.py` + e2e статусных переходов | `docker compose exec -T backend python -m unittest tests.test_billing_flow tests.test_invoices tests.test_admin_universal_crud -v`; валидация автогенерации счета при billing-статусе и фиксации оплаты только при ADMIN->`Оплачено` (в т.ч. множественные оплаты в одной заявке) |
|
||||
| P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` |
|
||||
| P27 | Итоговые E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон |
|
||||
| P27 | Итоговые E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + прогон Playwright через сервис `e2e` (образ `law-e2e-playwright:1.58.2`) |
|
||||
| P28 | Все таблицы БД в справочниках (+ `clients`, если добавляется) | `tests/test_admin_universal_crud.py`, `tests/test_migrations.py`, UI e2e admin dictionaries | миграции + `python -m unittest tests.test_admin_universal_crud tests.test_migrations -v` + e2e admin |
|
||||
| P29 | Единая модальная форма заявки + тема обращения + удаление рекомендаций | `e2e/tests/public_client_flow.spec.js` + UI smoke лендинга | прогон Playwright через `docker compose run --rm --no-deps e2e ...` + ручная проверка текста/полей на лендинге |
|
||||
| P30 | Отдельная страница работы с заявкой клиента | новые e2e для client workspace route + `tests/test_public_cabinet.py` | добавить e2e route-flow + прогон `test_public_cabinet` |
|
||||
| P31 | Вход клиента через phone+OTP модалку | новые e2e OTP modal flow + `tests/test_otp_rate_limit.py`, `tests/test_public_requests.py` | e2e + backend OTP тесты |
|
||||
| P32 | Переключение между заявками клиента | новые e2e multi-request flow + `tests/test_public_cabinet.py` | e2e multi-request + backend regression |
|
||||
| P33 | Чат в отдельном сервисе | `tests/test_public_cabinet.py`, `tests/test_admin_universal_crud.py` (chat service cases) + UI smoke (`client.js`, `admin.jsx`) | `docker compose run --rm backend python -m unittest tests.test_public_cabinet tests.test_admin_universal_crud -v` + фронт-сборка `admin.jsx` |
|
||||
| P34 | Ненавязчивые цитаты в блоке «Первая консультация» | UI e2e/smoke лендинга | визуальная регрессия лендинга + Playwright public smoke |
|
||||
| P35 | Предпросмотр документов | `tests/test_uploads_s3.py` (`test_public_attachment_object_preview_returns_inline_response`) + Playwright (`e2e/tests/public_client_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`) | `docker compose run --rm backend python -m unittest tests.test_uploads_s3 -v` + Playwright UI-прогон preview в клиенте и во вкладке работы с заявкой юриста/админа через сервис `e2e` |
|
||||
| P36 | Навигация в админку и редиректы | `e2e/tests/admin_entry_flow.spec.js` + redirect checks | Playwright `admin_entry_flow` + `curl -I -H 'Host: localhost:8081' http://localhost:8081/admin` (ожидается `302` и `Location: /admin.html`) + `curl -I http://localhost:8081/admin.html` |
|
||||
| P37 | Единые bootstrap-креды админа | `tests/test_admin_auth.py` + auth smoke (`/api/admin/auth/login`) + docs consistency check | `docker compose run --rm backend python -m unittest tests.test_admin_auth -v` + UI/API login smoke с `admin@example.com` / `admin123` |
|
||||
| P38 | Конструктор маршрутов статусов (темы) | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py` + новый e2e `e2e/tests/admin_status_designer_flow.spec.js` | backend: валидация графа переходов/SLA/требуемых документов; UI: создание/редактирование ветвлений, возвратов, терминальных переходов |
|
||||
| P39 | Канбан заявок для LAWYER/ADMIN | `tests/test_admin_universal_crud.py`, `tests/test_dashboard_finance.py` + новые e2e `e2e/tests/lawyer_kanban_flow.spec.js`, `e2e/tests/admin_kanban_flow.spec.js` | Проверить группировку статусов, ролевой scope карточек, перемещение по допустимым переходам, отображение дедлайнов SLA и индикаторов новых сообщений/файлов |
|
||||
|
||||
## Ролевое покрытие (PUBLIC / LAWYER / ADMIN)
|
||||
### PUBLIC (клиент)
|
||||
|
|
@ -68,7 +89,7 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
|
|||
- Публичные счета и PDF в кабинете: `tests/test_invoices.py`.
|
||||
|
||||
### LAWYER (юрист)
|
||||
- UI e2e: `e2e/tests/lawyer_role_flow.spec.js` (вход, claim неназначенной заявки, чтение обновлений, смена статуса).
|
||||
- UI e2e: `e2e/tests/lawyer_role_flow.spec.js` (вход, claim неназначенной заявки, новая вкладка работы с заявкой, чтение обновлений, смена статуса).
|
||||
- Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`.
|
||||
- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`.
|
||||
- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`.
|
||||
|
|
@ -79,6 +100,8 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
|
|||
|
||||
### ADMIN (администратор)
|
||||
- UI e2e: `e2e/tests/admin_role_flow.spec.js` (вход, справочники, создание пользователя/темы, создание и оплата счета).
|
||||
- UI e2e entry/redirect smoke: `e2e/tests/admin_entry_flow.spec.js` (нет CTA админки на лендинге, вход через `/admin`).
|
||||
- Bootstrap-auth: `tests/test_admin_auth.py` (автосоздание bootstrap-admin и негативные кейсы логина).
|
||||
- CRUD пользователей/юристов (пароли, роли, профильная тема, аватар): `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py`.
|
||||
- Темы и флоу статусов (включая ветвление), SLA-переходы: `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`.
|
||||
- Шаблоны обязательных/дозапрашиваемых данных: `tests/test_admin_universal_crud.py`, `tests/test_public_requests.py`.
|
||||
|
|
@ -94,6 +117,9 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
|
|||
5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose.
|
||||
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
|
||||
|
||||
## Последний регрессионный прогон
|
||||
- `python -m unittest discover -s tests -p 'test_*.py' -v` — `94 tests OK`.
|
||||
- `Playwright UI roles` (`e2e/tests/admin_role_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`, `e2e/tests/public_client_flow.spec.js`) — `3 passed`.
|
||||
## Последние подтвержденные прогоны
|
||||
- `docker compose run --rm backend python -m unittest -v tests.test_admin_auth` — `3 passed`.
|
||||
- `docker compose run --rm backend python -m unittest discover -s tests -p 'test_*.py' -v` — `105 passed`.
|
||||
- `docker compose run --rm backend python -m compileall app tests alembic` — успешно.
|
||||
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test tests/admin_entry_flow.spec.js --config=playwright.config.js` — `1 passed`.
|
||||
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend -e E2E_ADMIN_EMAIL=admin@example.com -e E2E_ADMIN_PASSWORD=admin123 -e E2E_LAWYER_EMAIL=ivan@mail.ru -e E2E_LAWYER_PASSWORD='LawyerPass-123!' e2e playwright test --config=playwright.config.js` — `4 passed` (рольовые e2e: `admin_entry_flow`, `admin_role_flow`, `lawyer_role_flow`, `public_client_flow`).
|
||||
|
|
|
|||
49
context/12_iteration_checkpoint_2026-02-24.md
Normal file
49
context/12_iteration_checkpoint_2026-02-24.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Чекпоинт Контекста (24 февраля 2026)
|
||||
|
||||
## Цель документа
|
||||
Зафиксировать фактический стейт после реализации блока `P28-P32` и подготовить остаток очереди `P33-P37`.
|
||||
|
||||
## Подтвержденный текущий стейт
|
||||
- Добавлена обязательная сущность клиента: новая таблица `clients` и миграция `0015_clients_table_links`.
|
||||
- В `requests` и `invoices` добавлены ссылки `client_id` + серверная логика автопривязки клиента по телефону.
|
||||
- В public API добавлены:
|
||||
- `GET /api/public/requests/topics` (темы для формы заявки),
|
||||
- `GET /api/public/requests/my` (список заявок авторизованного клиента),
|
||||
- phone-based VIEW OTP (`/api/public/otp/send|verify` с `client_phone`).
|
||||
- Доступ к заявке в public-контуре поддерживает оба сценария:
|
||||
- legacy по `track_number`,
|
||||
- новый по `client_phone` с переключением между заявками.
|
||||
- Лендинг обновлен:
|
||||
- форма создания заявки остается модальной,
|
||||
- блок и поле рекомендаций удалены,
|
||||
- выбор темы возвращен в форму,
|
||||
- добавлена OTP-модалка входа на клиентскую страницу.
|
||||
- Реализована отдельная страница клиента `client.html` (статус, чат, файлы, счета, таймлайн, переключение между заявками).
|
||||
- Реализован предпросмотр вложений (`pdf/jpg/mp4`) в клиентском кабинете и в рабочей вкладке заявки юриста/админа.
|
||||
- Legacy-модалка заявки в админке удалена: работа по заявке ведется через отдельную вкладку `/admin.html?view=request&requestId=...` с breadcrumb-навигацией.
|
||||
- Зафиксирован Docker-образ для UI E2E: `law-e2e-playwright:1.58.2` (service `e2e` в `docker-compose`), чтобы не скачивать Playwright/браузеры на каждом прогоне.
|
||||
- Цитаты перенесены в ненавязчивый формат в блок «Первая консультация» (hero panel).
|
||||
- Удалена кнопка «Админ-панель» с лендинга; вход в админку выполняется через маршрут `/admin` -> `/admin.html`.
|
||||
- Добавлен bootstrap-login администратора (`admin@example.com` / `admin123`) с автосозданием пользователя при первом входе.
|
||||
|
||||
## Проверка реализации P28-P32
|
||||
1. **Справочники и таблица клиентов**:
|
||||
- Таблица `clients` добавлена миграцией.
|
||||
- `admin/crud/meta/tables` теперь включает `clients`.
|
||||
2. **Модалка заявки**:
|
||||
- Поле рекомендации удалено.
|
||||
- Добавлен выбор темы обращения.
|
||||
3. **Отдельная страница клиента**:
|
||||
- Кабинет вынесен в `client.html` + `client.js` + `client.css`.
|
||||
4. **OTP вход по телефону и переход на страницу**:
|
||||
- На лендинге добавлена модалка phone+OTP.
|
||||
- При валидной сессии переход выполняется напрямую.
|
||||
5. **Переключение между заявками**:
|
||||
- На `client.html` реализован селектор заявок по endpoint `/api/public/requests/my`.
|
||||
|
||||
## Привязка к следующей итерации
|
||||
- `P33` — выполнен: чат вынесен в отдельный сервисный слой и отдельные API-контуры (`/api/public/chat`, `/api/admin/chat`).
|
||||
- `P34` — выполнен: цитаты перенесены в ненавязчивый вид в блок «Первая консультация».
|
||||
- `P35` — выполнен: добавлен UI preview + backend тест `test_public_attachment_object_preview_returns_inline_response`.
|
||||
- `P36` — выполнен: удалена админ-кнопка с лендинга, добавлен e2e smoke `admin_entry_flow`, редирект `/admin` валидирован.
|
||||
- `P37` — выполнен: единый стандарт админ-кредов, реализован bootstrap-login и автотесты `tests/test_admin_auth.py`.
|
||||
|
|
@ -7,6 +7,21 @@ services:
|
|||
depends_on: [backend]
|
||||
ports: ["8081:80"]
|
||||
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: e2e/Dockerfile
|
||||
image: law-e2e-playwright:1.58.2
|
||||
container_name: law-e2e
|
||||
working_dir: /src/e2e
|
||||
depends_on: [frontend]
|
||||
volumes:
|
||||
- .:/src
|
||||
- /src/e2e/node_modules
|
||||
environment:
|
||||
NODE_PATH: /opt/e2e/node_modules
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
|
||||
|
||||
backend:
|
||||
build: .
|
||||
container_name: law-backend
|
||||
|
|
|
|||
10
e2e/Dockerfile
Normal file
10
e2e/Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
FROM mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
|
||||
WORKDIR /opt/e2e
|
||||
|
||||
COPY e2e/package.json e2e/package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
ENV NODE_PATH=/opt/e2e/node_modules
|
||||
ENV PATH=/opt/e2e/node_modules/.bin:$PATH
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
246
e2e/package-lock.json
generated
Normal file
246
e2e/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
{
|
||||
"name": "law-e2e",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "law-e2e",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
e2e/playwright.config.js
Normal file
29
e2e/playwright.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const { defineConfig } = require("@playwright/test");
|
||||
const chromiumPath = process.env.E2E_CHROMIUM_PATH || "";
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 90_000,
|
||||
workers: 1,
|
||||
expect: {
|
||||
timeout: 15_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { outputFolder: "playwright-report", open: "never" }],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || "http://localhost:8081",
|
||||
headless: true,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
launchOptions: chromiumPath
|
||||
? {
|
||||
executablePath: chromiumPath,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
13
e2e/tests/admin_entry_flow.spec.js
Normal file
13
e2e/tests/admin_entry_flow.spec.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
test("admin entry via route only: landing has no admin CTA and /admin opens panel", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("link", { name: "Админ-панель" })).toHaveCount(0);
|
||||
|
||||
await page.goto("/admin");
|
||||
await expect(async () => {
|
||||
const loginVisible = await page.locator("#login-email").isVisible().catch(() => false);
|
||||
const panelVisible = await page.getByRole("heading", { name: "Панель администратора" }).isVisible().catch(() => false);
|
||||
expect(loginVisible || panelVisible).toBeTruthy();
|
||||
}).toPass({ timeout: 30_000 });
|
||||
});
|
||||
110
e2e/tests/admin_role_flow.spec.js
Normal file
110
e2e/tests/admin_role_flow.spec.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
preparePublicSession,
|
||||
createRequestViaLanding,
|
||||
randomPhone,
|
||||
loginAdminPanel,
|
||||
openDictionaryTree,
|
||||
selectDictionaryNode,
|
||||
rowByTrack,
|
||||
} = require("./helpers");
|
||||
|
||||
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
||||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
|
||||
|
||||
test("admin flow via UI: dictionaries + users + topics + invoices", async ({ context, page }) => {
|
||||
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||
const phone = randomPhone();
|
||||
|
||||
await preparePublicSession(context, page, appUrl, phone);
|
||||
const { trackNumber } = await createRequestViaLanding(page, {
|
||||
phone,
|
||||
description: "Заявка для проверки админского UI-флоу",
|
||||
});
|
||||
|
||||
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
||||
await expect(page.locator(".badge")).toContainText("роль: Администратор");
|
||||
await expect(page.locator("#section-dashboard h2")).toHaveText("Обзор метрик");
|
||||
await expect(page.locator("#section-dashboard")).toContainText("Загрузка юристов");
|
||||
|
||||
await openDictionaryTree(page);
|
||||
await expect(page.locator("aside .menu .menu-tree")).toContainText("Темы");
|
||||
await expect(page.locator("aside .menu .menu-tree")).toContainText("Статусы");
|
||||
await expect(page.locator("aside .menu .menu-tree")).toContainText("Переходы статусов");
|
||||
await expect(page.locator("aside .menu .menu-tree")).toContainText("Пользователи");
|
||||
await expect(page.locator("aside .menu .menu-tree")).toContainText("Цитаты");
|
||||
|
||||
const unique = Date.now();
|
||||
const lawyerEmail = `ui-lawyer-${unique}@example.com`;
|
||||
const topicName = `Тема UI ${unique}`;
|
||||
|
||||
await selectDictionaryNode(page, "Пользователи");
|
||||
await page.locator("#section-config .config-panel").getByRole("button", { name: "Добавить" }).click();
|
||||
await expect(page.getByRole("heading", { name: /Создание • Пользователи/ })).toBeVisible();
|
||||
await page.locator("#record-field-name").fill(`Юрист UI ${unique}`);
|
||||
await page.locator("#record-field-email").fill(lawyerEmail);
|
||||
await page.locator("#record-field-role").selectOption("LAWYER");
|
||||
await page.locator("#record-field-default_rate").fill("5000");
|
||||
await page.locator("#record-field-salary_percent").fill("35");
|
||||
await page.locator("#record-field-password").fill("UiLawyerPass-123!");
|
||||
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
||||
await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
|
||||
await expect(page.locator("#section-config table")).toContainText(lawyerEmail);
|
||||
|
||||
await selectDictionaryNode(page, "Темы");
|
||||
await page.locator("#section-config .config-panel").getByRole("button", { name: "Добавить" }).click();
|
||||
await expect(page.getByRole("heading", { name: /Создание • Темы/ })).toBeVisible();
|
||||
await page.locator("#record-field-name").fill(topicName);
|
||||
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
||||
await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
|
||||
|
||||
const topicRow = page.locator("#section-config table tbody tr").filter({ hasText: topicName });
|
||||
await expect(topicRow).toHaveCount(1);
|
||||
const topicCode = (await topicRow.first().locator("td code").innerText()).trim();
|
||||
|
||||
await page.locator("aside .menu button[data-section='invoices']").click();
|
||||
await expect(page.locator("#section-invoices h2")).toHaveText("Счета");
|
||||
await page.locator("#section-invoices").getByRole("button", { name: "Новый счет" }).click();
|
||||
await expect(page.getByRole("heading", { name: /Создание • Счета/ })).toBeVisible();
|
||||
await page.locator("#record-field-request_track_number").fill(trackNumber);
|
||||
await page.locator("#record-field-amount").fill("15000");
|
||||
await page.locator("#record-field-payer_display_name").fill("Тестовый плательщик");
|
||||
await page.locator("#record-field-payer_details").fill('{"inn":"7700000000"}');
|
||||
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
||||
await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
|
||||
|
||||
const invoiceRow = rowByTrack(page, "#section-invoices", trackNumber);
|
||||
await expect(invoiceRow).toHaveCount(1);
|
||||
await expect(invoiceRow.first()).toContainText("15000");
|
||||
|
||||
await invoiceRow.first().getByRole("button", { name: "Редактировать счет" }).click();
|
||||
await expect(page.getByRole("heading", { name: /Редактирование • Счета/ })).toBeVisible();
|
||||
await page.locator("#record-field-status").selectOption("PAID");
|
||||
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
||||
await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
|
||||
await expect(invoiceRow.first()).toContainText("Оплачен");
|
||||
|
||||
await page.goto("/admin.html?section=availableTables");
|
||||
await expect(page.locator("#section-available-tables h2")).toHaveText("Доступность таблиц");
|
||||
|
||||
const clientsRow = page.locator("#section-available-tables table tbody tr").filter({ hasText: "clients" }).first();
|
||||
await expect(clientsRow).toHaveCount(1);
|
||||
const deactivateBtn = clientsRow.getByRole("button", { name: "Деактивировать таблицу" });
|
||||
if (await deactivateBtn.count()) {
|
||||
await deactivateBtn.click();
|
||||
}
|
||||
await expect(page.locator("#section-available-tables .status")).toContainText(/Сохранено|Список обновлен/);
|
||||
|
||||
await openDictionaryTree(page);
|
||||
await expect(page.locator("aside .menu .menu-tree")).not.toContainText("Клиенты");
|
||||
|
||||
await page.goto("/admin.html?section=availableTables");
|
||||
await expect(page.locator("#section-available-tables h2")).toHaveText("Доступность таблиц");
|
||||
const clientsRowDisabled = page.locator("#section-available-tables table tbody tr").filter({ hasText: "clients" }).first();
|
||||
await expect(clientsRowDisabled.getByRole("button", { name: "Активировать таблицу" })).toHaveCount(1);
|
||||
await clientsRowDisabled.getByRole("button", { name: "Активировать таблицу" }).click();
|
||||
await expect(page.locator("#section-available-tables .status")).toContainText(/Сохранено|Список обновлен/);
|
||||
|
||||
await openDictionaryTree(page);
|
||||
await expect(page.locator("aside .menu .menu-tree")).toContainText("Клиенты");
|
||||
});
|
||||
215
e2e/tests/helpers.js
Normal file
215
e2e/tests/helpers.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
const path = require("path");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const dotenv = require("dotenv");
|
||||
const { expect } = require("@playwright/test");
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
|
||||
|
||||
const PUBLIC_SECRET = process.env.PUBLIC_JWT_SECRET || "change_me_public";
|
||||
const PUBLIC_COOKIE_NAME = process.env.PUBLIC_COOKIE_NAME || "public_jwt";
|
||||
|
||||
function randomDigits(length) {
|
||||
let value = "";
|
||||
while (value.length < length) {
|
||||
value += String(Math.floor(Math.random() * 10));
|
||||
}
|
||||
return value.slice(0, length);
|
||||
}
|
||||
|
||||
function randomPhone() {
|
||||
return `+79${randomDigits(9)}`;
|
||||
}
|
||||
|
||||
function createPublicCookieToken(phone) {
|
||||
return jwt.sign({ sub: phone, purpose: "CREATE_REQUEST" }, PUBLIC_SECRET, {
|
||||
algorithm: "HS256",
|
||||
expiresIn: "7d",
|
||||
});
|
||||
}
|
||||
|
||||
async function installPromptAutoAccept(page, code = "000000") {
|
||||
page.on("dialog", async (dialog) => {
|
||||
if (dialog.type() === "prompt") {
|
||||
await dialog.accept(code);
|
||||
return;
|
||||
}
|
||||
await dialog.accept();
|
||||
});
|
||||
}
|
||||
|
||||
async function installOtpBypassRoutes(page) {
|
||||
await page.route("**/api/public/otp/send", async (route) => {
|
||||
let purpose = "CREATE_REQUEST";
|
||||
try {
|
||||
const body = JSON.parse(route.request().postData() || "{}");
|
||||
purpose = String(body.purpose || purpose);
|
||||
} catch (_) {}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "sent",
|
||||
purpose,
|
||||
ttl_seconds: 600,
|
||||
sms_response: { provider: "e2e", status: "accepted", message: "ok" },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/public/otp/verify", async (route) => {
|
||||
let purpose = "CREATE_REQUEST";
|
||||
try {
|
||||
const body = JSON.parse(route.request().postData() || "{}");
|
||||
purpose = String(body.purpose || purpose);
|
||||
} catch (_) {}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "verified", purpose }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function preparePublicSession(context, page, appUrl, phone) {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: PUBLIC_COOKIE_NAME,
|
||||
value: createPublicCookieToken(phone),
|
||||
url: `${appUrl}/`,
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
]);
|
||||
|
||||
await installPromptAutoAccept(page);
|
||||
await installOtpBypassRoutes(page);
|
||||
}
|
||||
|
||||
async function createRequestViaLanding(page, options = {}) {
|
||||
const phone = options.phone || randomPhone();
|
||||
const name = options.name || `Клиент E2E ${Date.now()}`;
|
||||
const description = options.description || "Проверка создания заявки через UI";
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Решаем сложные юридические задачи в интересах вашего бизнеса." })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Оставить заявку" }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "Создание заявки" })).toBeVisible();
|
||||
|
||||
await page.locator("#name").fill(name);
|
||||
await page.locator("#phone").fill(phone);
|
||||
const topicSelect = page.locator("#topic");
|
||||
await topicSelect.waitFor();
|
||||
await topicSelect.selectOption({ index: 1 });
|
||||
await page.locator("#description").fill(description);
|
||||
await page.getByRole("button", { name: "Отправить заявку" }).click();
|
||||
|
||||
await expect(page.locator("#form-status")).toContainText("Заявка принята. Номер:");
|
||||
const statusText = await page.locator("#form-status").innerText();
|
||||
const match = statusText.match(/TRK-[A-Z0-9-]+/);
|
||||
if (!match) throw new Error("Track number not found in form status");
|
||||
|
||||
return { trackNumber: match[0], phone, name };
|
||||
}
|
||||
|
||||
async function openPublicCabinet(page, trackNumber) {
|
||||
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
|
||||
await expect(page.locator("#client-page-status")).toContainText(`Открыта заявка: ${trackNumber}`);
|
||||
await expect(page.locator("#cabinet-summary")).toBeVisible();
|
||||
await expect(page.locator("#cabinet-request-status")).not.toHaveText("-");
|
||||
}
|
||||
|
||||
async function sendCabinetMessage(page, text) {
|
||||
await page.locator("#cabinet-chat-body").fill(text);
|
||||
await page.locator("#cabinet-chat-send").click();
|
||||
await expect(page.locator("#client-page-status")).toContainText("Сообщение отправлено.");
|
||||
await expect(page.locator("#cabinet-messages")).toContainText(text);
|
||||
}
|
||||
|
||||
async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E file") {
|
||||
let lastError = null;
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
await page.locator("#cabinet-file-input").setInputFiles({
|
||||
name: fileName,
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(bodyText, "utf-8"),
|
||||
});
|
||||
await page.locator("#cabinet-file-upload").click();
|
||||
|
||||
try {
|
||||
await expect(page.locator("#client-page-status")).toContainText("Файл загружен.", { timeout: 20_000 });
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
await expect(page.locator("#cabinet-files")).toContainText(fileName);
|
||||
}
|
||||
|
||||
async function loginAdminPanel(page, creds) {
|
||||
await page.goto("/admin");
|
||||
await expect(page.getByRole("heading", { name: "Панель администратора" })).toBeVisible();
|
||||
|
||||
let loginVisible = false;
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 15_000) {
|
||||
loginVisible = await page.locator("#login-email").isVisible().catch(() => false);
|
||||
if (loginVisible) break;
|
||||
const badge = (await page.locator(".badge").first().textContent().catch(() => "")) || "";
|
||||
if (badge && !badge.includes("роль: -")) break;
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
if (loginVisible) {
|
||||
await page.locator("#login-email").fill(creds.email);
|
||||
await page.locator("#login-password").fill(creds.password);
|
||||
await page.getByRole("button", { name: "Войти" }).click();
|
||||
}
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Панель администратора" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function openRequestsSection(page) {
|
||||
await page.locator("aside .menu button[data-section='requests']").click();
|
||||
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
|
||||
}
|
||||
|
||||
function rowByTrack(page, sectionSelector, trackNumber) {
|
||||
return page.locator(`${sectionSelector} table tbody tr`).filter({ hasText: trackNumber });
|
||||
}
|
||||
|
||||
async function openDictionaryTree(page) {
|
||||
const treeButton = page.locator("aside .menu button", { hasText: "Справочники" }).first();
|
||||
await treeButton.click();
|
||||
const afterFirstClick = await treeButton.innerText();
|
||||
if (afterFirstClick.includes("▸")) {
|
||||
await treeButton.click();
|
||||
}
|
||||
await expect(page.locator("#section-config h2")).toHaveText("Справочники");
|
||||
await expect(treeButton).toContainText("▾");
|
||||
await expect.poll(async () => page.locator("aside .menu .menu-tree button").count(), { timeout: 30_000 }).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
async function selectDictionaryNode(page, label) {
|
||||
await page.locator("aside .menu .menu-tree").getByRole("button", { name: label, exact: true }).click();
|
||||
await expect(page.locator("#section-config .config-panel h3")).toContainText(label);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
randomPhone,
|
||||
preparePublicSession,
|
||||
createRequestViaLanding,
|
||||
openPublicCabinet,
|
||||
sendCabinetMessage,
|
||||
uploadCabinetFile,
|
||||
loginAdminPanel,
|
||||
openRequestsSection,
|
||||
rowByTrack,
|
||||
openDictionaryTree,
|
||||
selectDictionaryNode,
|
||||
};
|
||||
95
e2e/tests/lawyer_role_flow.spec.js
Normal file
95
e2e/tests/lawyer_role_flow.spec.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
preparePublicSession,
|
||||
createRequestViaLanding,
|
||||
openPublicCabinet,
|
||||
sendCabinetMessage,
|
||||
uploadCabinetFile,
|
||||
randomPhone,
|
||||
loginAdminPanel,
|
||||
openRequestsSection,
|
||||
rowByTrack,
|
||||
} = require("./helpers");
|
||||
|
||||
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
|
||||
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
|
||||
|
||||
test("lawyer flow via UI: claim request -> chat and files in request workspace tab -> change status", async ({ context, page }) => {
|
||||
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||
const phone = randomPhone();
|
||||
|
||||
await preparePublicSession(context, page, appUrl, phone);
|
||||
|
||||
const { trackNumber } = await createRequestViaLanding(page, {
|
||||
phone,
|
||||
description: "Заявка для проверки флоу юриста через UI",
|
||||
});
|
||||
|
||||
await openPublicCabinet(page, trackNumber);
|
||||
await sendCabinetMessage(page, `Сообщение юристу ${Date.now()}`);
|
||||
const clientFileName = `lawyer-client-${Date.now()}.txt`;
|
||||
await uploadCabinetFile(page, clientFileName, "lawyer unread marker");
|
||||
|
||||
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
|
||||
await expect(page.locator(".badge")).toContainText("роль: Юрист");
|
||||
|
||||
await openRequestsSection(page);
|
||||
|
||||
const row = rowByTrack(page, "#section-requests", trackNumber);
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(row.first().locator(".request-update-chip")).toBeVisible();
|
||||
|
||||
const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
|
||||
await expect(claimBtn).toBeVisible();
|
||||
await claimBtn.click();
|
||||
await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/);
|
||||
|
||||
const requestPagePromise = context.waitForEvent("page");
|
||||
await row.first().getByRole("button", { name: "Открыть заявку" }).click();
|
||||
const requestPage = await requestPagePromise;
|
||||
await requestPage.waitForLoadState("domcontentloaded");
|
||||
await expect(requestPage.getByRole("heading", { name: "Карточка заявки" })).toBeVisible();
|
||||
await expect(requestPage.locator("#section-request-workspace .breadcrumbs")).toContainText("Заявки -> Заявка");
|
||||
await expect(requestPage.getByRole("button", { name: "Назад к заявкам" })).toBeVisible();
|
||||
await expect(requestPage.locator("#request-modal-messages")).toContainText("Сообщение юристу");
|
||||
await expect(requestPage.locator("#request-modal-files")).toContainText(clientFileName);
|
||||
const clientFileRow = requestPage.locator("#request-modal-files li").filter({ hasText: clientFileName }).first();
|
||||
await clientFileRow.getByRole("button", { name: /Предпросмотр/ }).click();
|
||||
await expect(requestPage.locator("#request-file-preview-overlay")).toBeVisible();
|
||||
await expect(requestPage.locator("#request-file-preview-overlay .request-preview-frame")).toBeVisible();
|
||||
await requestPage.locator("#request-file-preview-overlay .close").click();
|
||||
|
||||
const lawyerMessage = `Ответ юриста ${Date.now()}`;
|
||||
await requestPage.locator("#request-modal-message-body").fill(lawyerMessage);
|
||||
await requestPage.locator("#request-modal-message-send").click();
|
||||
await expect(requestPage.locator("#section-request-workspace .status")).toContainText("Сообщение отправлено");
|
||||
await expect(requestPage.locator("#request-modal-messages")).toContainText(lawyerMessage);
|
||||
|
||||
const lawyerFileName = `lawyer-admin-${Date.now()}.pdf`;
|
||||
await requestPage.locator("#request-modal-file-input").setInputFiles({
|
||||
name: lawyerFileName,
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("lawyer file from admin modal", "utf-8"),
|
||||
});
|
||||
await requestPage.locator("#request-modal-file-upload").click();
|
||||
await expect(requestPage.locator("#section-request-workspace .status")).toContainText("Файл загружен");
|
||||
await expect(requestPage.locator("#request-modal-files")).toContainText(lawyerFileName);
|
||||
await requestPage.close();
|
||||
|
||||
await page.locator("aside .menu button[data-section='requests']").click();
|
||||
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
|
||||
await page.locator("#section-requests").getByRole("button", { name: "Обновить" }).click();
|
||||
await expect(row.first().locator(".request-update-empty")).toContainText("нет");
|
||||
|
||||
await row.first().getByRole("button", { name: "Редактировать заявку" }).click();
|
||||
await expect(page.getByRole("heading", { name: /Редактирование • Заявки/ })).toBeVisible();
|
||||
await page.locator("#record-field-status_code").selectOption("IN_PROGRESS");
|
||||
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
||||
await expect(page.locator("#section-requests .status")).toContainText("Список обновлен");
|
||||
await expect(row.first()).toContainText("В работе");
|
||||
|
||||
await page.goto("/");
|
||||
await openPublicCabinet(page, trackNumber);
|
||||
await expect(page.locator("#cabinet-messages")).toContainText(lawyerMessage);
|
||||
await expect(page.locator("#cabinet-files")).toContainText(lawyerFileName);
|
||||
});
|
||||
33
e2e/tests/public_client_flow.spec.js
Normal file
33
e2e/tests/public_client_flow.spec.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
const { test } = require("@playwright/test");
|
||||
const {
|
||||
preparePublicSession,
|
||||
createRequestViaLanding,
|
||||
openPublicCabinet,
|
||||
sendCabinetMessage,
|
||||
uploadCabinetFile,
|
||||
randomPhone,
|
||||
} = require("./helpers");
|
||||
|
||||
test("public flow via UI: landing -> create request -> cabinet -> chat -> upload file", async ({ context, page }) => {
|
||||
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||
const phone = randomPhone();
|
||||
|
||||
await preparePublicSession(context, page, appUrl, phone);
|
||||
|
||||
const { trackNumber } = await createRequestViaLanding(page, {
|
||||
phone,
|
||||
description: "Проверка публичного E2E флоу через UI.",
|
||||
});
|
||||
|
||||
await openPublicCabinet(page, trackNumber);
|
||||
|
||||
const message = `Сообщение из e2e ${Date.now()}`;
|
||||
await sendCabinetMessage(page, message);
|
||||
|
||||
const uploadedFile = `public-${Date.now()}.pdf`;
|
||||
await uploadCabinetFile(page, uploadedFile, "public file content");
|
||||
const fileRow = page.locator("#cabinet-files .simple-item").filter({ hasText: uploadedFile }).first();
|
||||
await fileRow.getByRole("button", { name: "Предпросмотр" }).click();
|
||||
await page.locator("#file-preview-overlay #file-preview-body").waitFor();
|
||||
await page.locator("#file-preview-close").click();
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ server {
|
|||
listen 80;
|
||||
server_name _;
|
||||
server_tokens off;
|
||||
absolute_redirect off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
|
|
|||
125
tests/test_admin_auth.py
Normal file
125
tests/test_admin_auth.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import os
|
||||
import unittest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, delete
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
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.core.config import settings
|
||||
from app.core.security import decode_jwt, hash_password
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.models.admin_user import AdminUser
|
||||
|
||||
|
||||
class AdminAuthTests(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)
|
||||
AdminUser.__table__.create(bind=cls.engine)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
AdminUser.__table__.drop(bind=cls.engine)
|
||||
cls.engine.dispose()
|
||||
|
||||
def setUp(self):
|
||||
with self.SessionLocal() as db:
|
||||
db.execute(delete(AdminUser))
|
||||
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)
|
||||
|
||||
self._settings_backup = {
|
||||
"ADMIN_BOOTSTRAP_ENABLED": settings.ADMIN_BOOTSTRAP_ENABLED,
|
||||
"ADMIN_BOOTSTRAP_EMAIL": settings.ADMIN_BOOTSTRAP_EMAIL,
|
||||
"ADMIN_BOOTSTRAP_PASSWORD": settings.ADMIN_BOOTSTRAP_PASSWORD,
|
||||
"ADMIN_BOOTSTRAP_NAME": settings.ADMIN_BOOTSTRAP_NAME,
|
||||
}
|
||||
settings.ADMIN_BOOTSTRAP_ENABLED = True
|
||||
settings.ADMIN_BOOTSTRAP_EMAIL = "admin@example.com"
|
||||
settings.ADMIN_BOOTSTRAP_PASSWORD = "admin123"
|
||||
settings.ADMIN_BOOTSTRAP_NAME = "Администратор системы"
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
app.dependency_overrides.clear()
|
||||
for key, value in self._settings_backup.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
def test_login_bootstraps_admin_when_absent(self):
|
||||
response = self.client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"email": "admin@example.com", "password": "admin123"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
token = response.json().get("access_token")
|
||||
self.assertTrue(token)
|
||||
|
||||
claims = decode_jwt(token, settings.ADMIN_JWT_SECRET)
|
||||
self.assertEqual(claims.get("email"), "admin@example.com")
|
||||
self.assertEqual(claims.get("role"), "ADMIN")
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
row = db.query(AdminUser).filter(AdminUser.email == "admin@example.com").first()
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row.role, "ADMIN")
|
||||
self.assertTrue(bool(row.is_active))
|
||||
|
||||
def test_login_rejects_wrong_bootstrap_password(self):
|
||||
response = self.client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"email": "admin@example.com", "password": "wrong-password"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
count = db.query(AdminUser).count()
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_existing_admin_is_normalized_to_bootstrap_credentials(self):
|
||||
with self.SessionLocal() as db:
|
||||
db.add(
|
||||
AdminUser(
|
||||
role="ADMIN",
|
||||
name="Администратор",
|
||||
email="admin@example.com",
|
||||
password_hash=hash_password("custom-pass-1"),
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
ok = self.client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"email": "admin@example.com", "password": "admin123"},
|
||||
)
|
||||
self.assertEqual(ok.status_code, 200)
|
||||
self.assertTrue(ok.json().get("access_token"))
|
||||
|
||||
wrong = self.client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"email": "admin@example.com", "password": "custom-pass-1"},
|
||||
)
|
||||
self.assertEqual(wrong.status_code, 401)
|
||||
|
|
@ -25,9 +25,11 @@ from app.models.admin_user import AdminUser
|
|||
from app.models.admin_user_topic import AdminUserTopic
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.client import Client
|
||||
from app.models.form_field import FormField
|
||||
from app.models.message import Message
|
||||
from app.models.notification import Notification
|
||||
from app.models.table_availability import TableAvailability
|
||||
from app.models.quote import Quote
|
||||
from app.models.request import Request
|
||||
from app.models.status import Status
|
||||
|
|
@ -49,6 +51,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
)
|
||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||
AdminUser.__table__.create(bind=cls.engine)
|
||||
Client.__table__.create(bind=cls.engine)
|
||||
Quote.__table__.create(bind=cls.engine)
|
||||
FormField.__table__.create(bind=cls.engine)
|
||||
Request.__table__.create(bind=cls.engine)
|
||||
|
|
@ -63,12 +66,14 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
TopicStatusTransition.__table__.create(bind=cls.engine)
|
||||
AdminUserTopic.__table__.create(bind=cls.engine)
|
||||
Notification.__table__.create(bind=cls.engine)
|
||||
TableAvailability.__table__.create(bind=cls.engine)
|
||||
AuditLog.__table__.create(bind=cls.engine)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
AuditLog.__table__.drop(bind=cls.engine)
|
||||
Notification.__table__.drop(bind=cls.engine)
|
||||
TableAvailability.__table__.drop(bind=cls.engine)
|
||||
AdminUserTopic.__table__.drop(bind=cls.engine)
|
||||
RequestDataRequirement.__table__.drop(bind=cls.engine)
|
||||
TopicDataTemplate.__table__.drop(bind=cls.engine)
|
||||
|
|
@ -82,6 +87,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
Request.__table__.drop(bind=cls.engine)
|
||||
FormField.__table__.drop(bind=cls.engine)
|
||||
Quote.__table__.drop(bind=cls.engine)
|
||||
Client.__table__.drop(bind=cls.engine)
|
||||
AdminUser.__table__.drop(bind=cls.engine)
|
||||
cls.engine.dispose()
|
||||
|
||||
|
|
@ -92,6 +98,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
db.execute(delete(Attachment))
|
||||
db.execute(delete(Message))
|
||||
db.execute(delete(Request))
|
||||
db.execute(delete(Client))
|
||||
db.execute(delete(Status))
|
||||
db.execute(delete(FormField))
|
||||
db.execute(delete(Topic))
|
||||
|
|
@ -101,6 +108,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
db.execute(delete(TopicStatusTransition))
|
||||
db.execute(delete(AdminUserTopic))
|
||||
db.execute(delete(Notification))
|
||||
db.execute(delete(TableAvailability))
|
||||
db.execute(delete(Quote))
|
||||
db.execute(delete(AdminUser))
|
||||
db.commit()
|
||||
|
|
@ -177,6 +185,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
by_table = {row["table"]: row for row in tables}
|
||||
self.assertIn("requests", by_table)
|
||||
self.assertIn("invoices", by_table)
|
||||
self.assertIn("clients", by_table)
|
||||
self.assertIn("quotes", by_table)
|
||||
self.assertIn("statuses", by_table)
|
||||
|
||||
|
|
@ -191,7 +200,12 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
self.assertEqual(quotes_columns["sort_order"]["label"], "Порядок")
|
||||
self.assertTrue(all(str(col.get("label") or "").strip() for col in (by_table["quotes"].get("columns") or [])))
|
||||
for table_name, table_meta in by_table.items():
|
||||
expected_section = "main" if table_name in {"requests", "invoices"} else "dictionary"
|
||||
if table_name in {"requests", "invoices"}:
|
||||
expected_section = "main"
|
||||
elif table_name == "table_availability":
|
||||
expected_section = "system"
|
||||
else:
|
||||
expected_section = "dictionary"
|
||||
self.assertEqual(table_meta.get("section"), expected_section)
|
||||
|
||||
admin_users_cols = {col["name"] for col in (by_table["admin_users"].get("columns") or [])}
|
||||
|
|
@ -201,6 +215,55 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
forbidden = self.client.get("/api/admin/crud/meta/tables", headers=lawyer_headers)
|
||||
self.assertEqual(forbidden.status_code, 403)
|
||||
|
||||
def test_admin_can_toggle_dictionary_table_visibility(self):
|
||||
admin_headers = self._auth_headers("ADMIN")
|
||||
available = self.client.get("/api/admin/crud/meta/available-tables", headers=admin_headers)
|
||||
self.assertEqual(available.status_code, 200)
|
||||
rows = available.json().get("rows") or []
|
||||
by_table = {row["table"]: row for row in rows}
|
||||
self.assertIn("clients", by_table)
|
||||
self.assertIn("table_availability", by_table)
|
||||
self.assertEqual(by_table["table_availability"]["section"], "system")
|
||||
self.assertTrue(bool(by_table["clients"]["is_active"]))
|
||||
|
||||
deactivated = self.client.patch(
|
||||
"/api/admin/crud/meta/available-tables/clients",
|
||||
headers=admin_headers,
|
||||
json={"is_active": False},
|
||||
)
|
||||
self.assertEqual(deactivated.status_code, 200)
|
||||
self.assertFalse(bool(deactivated.json().get("is_active")))
|
||||
|
||||
filtered_catalog = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
|
||||
self.assertEqual(filtered_catalog.status_code, 200)
|
||||
filtered_tables = {row["table"] for row in (filtered_catalog.json().get("tables") or [])}
|
||||
self.assertNotIn("clients", filtered_tables)
|
||||
self.assertIn("requests", filtered_tables)
|
||||
self.assertIn("invoices", filtered_tables)
|
||||
|
||||
activated = self.client.patch(
|
||||
"/api/admin/crud/meta/available-tables/clients",
|
||||
headers=admin_headers,
|
||||
json={"is_active": True},
|
||||
)
|
||||
self.assertEqual(activated.status_code, 200)
|
||||
self.assertTrue(bool(activated.json().get("is_active")))
|
||||
|
||||
refreshed_catalog = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
|
||||
self.assertEqual(refreshed_catalog.status_code, 200)
|
||||
refreshed_tables = {row["table"] for row in (refreshed_catalog.json().get("tables") or [])}
|
||||
self.assertIn("clients", refreshed_tables)
|
||||
|
||||
lawyer_headers = self._auth_headers("LAWYER")
|
||||
forbidden_list = self.client.get("/api/admin/crud/meta/available-tables", headers=lawyer_headers)
|
||||
self.assertEqual(forbidden_list.status_code, 403)
|
||||
forbidden_patch = self.client.patch(
|
||||
"/api/admin/crud/meta/available-tables/clients",
|
||||
headers=lawyer_headers,
|
||||
json={"is_active": False},
|
||||
)
|
||||
self.assertEqual(forbidden_patch.status_code, 403)
|
||||
|
||||
def test_lawyer_permissions_and_request_crud(self):
|
||||
lawyer_headers = self._auth_headers("LAWYER")
|
||||
|
||||
|
|
@ -347,6 +410,139 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
self.assertIsNotNone(refreshed)
|
||||
self.assertEqual(refreshed.status_code, "CLOSED")
|
||||
|
||||
def test_lawyer_messages_and_attachments_are_scoped_by_request_access(self):
|
||||
with self.SessionLocal() as db:
|
||||
lawyer_self = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Свой",
|
||||
email="lawyer.msg.self@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
lawyer_other = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Чужой",
|
||||
email="lawyer.msg.other@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([lawyer_self, lawyer_other])
|
||||
db.flush()
|
||||
self_id = str(lawyer_self.id)
|
||||
other_id = str(lawyer_other.id)
|
||||
|
||||
own = Request(
|
||||
track_number="TRK-MSG-OWN",
|
||||
client_name="Клиент Свой",
|
||||
client_phone="+79990010101",
|
||||
status_code="IN_PROGRESS",
|
||||
description="own",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=self_id,
|
||||
)
|
||||
foreign = Request(
|
||||
track_number="TRK-MSG-FOREIGN",
|
||||
client_name="Клиент Чужой",
|
||||
client_phone="+79990010102",
|
||||
status_code="IN_PROGRESS",
|
||||
description="foreign",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=other_id,
|
||||
)
|
||||
unassigned = Request(
|
||||
track_number="TRK-MSG-UNASSIGNED",
|
||||
client_name="Клиент Без назначения",
|
||||
client_phone="+79990010103",
|
||||
status_code="NEW",
|
||||
description="unassigned",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=None,
|
||||
)
|
||||
db.add_all([own, foreign, unassigned])
|
||||
db.flush()
|
||||
|
||||
msg_own = Message(request_id=own.id, author_type="CLIENT", author_name="Клиент", body="own", immutable=False)
|
||||
msg_foreign = Message(request_id=foreign.id, author_type="CLIENT", author_name="Клиент", body="foreign", immutable=False)
|
||||
msg_unassigned = Message(request_id=unassigned.id, author_type="CLIENT", author_name="Клиент", body="unassigned", immutable=False)
|
||||
db.add_all([msg_own, msg_foreign, msg_unassigned])
|
||||
db.flush()
|
||||
|
||||
att_own = Attachment(
|
||||
request_id=own.id,
|
||||
message_id=msg_own.id,
|
||||
file_name="own.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=100,
|
||||
s3_key=f"requests/{own.id}/own.pdf",
|
||||
immutable=False,
|
||||
)
|
||||
att_foreign = Attachment(
|
||||
request_id=foreign.id,
|
||||
message_id=msg_foreign.id,
|
||||
file_name="foreign.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=100,
|
||||
s3_key=f"requests/{foreign.id}/foreign.pdf",
|
||||
immutable=False,
|
||||
)
|
||||
att_unassigned = Attachment(
|
||||
request_id=unassigned.id,
|
||||
message_id=msg_unassigned.id,
|
||||
file_name="unassigned.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=100,
|
||||
s3_key=f"requests/{unassigned.id}/unassigned.pdf",
|
||||
immutable=False,
|
||||
)
|
||||
db.add_all([att_own, att_foreign, att_unassigned])
|
||||
db.commit()
|
||||
|
||||
own_id = str(own.id)
|
||||
unassigned_id = str(unassigned.id)
|
||||
foreign_msg_id = str(msg_foreign.id)
|
||||
foreign_att_id = str(att_foreign.id)
|
||||
|
||||
headers = self._auth_headers("LAWYER", email="lawyer.msg.self@example.com", sub=self_id)
|
||||
|
||||
messages_query = self.client.post(
|
||||
"/api/admin/crud/messages/query",
|
||||
headers=headers,
|
||||
json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}},
|
||||
)
|
||||
self.assertEqual(messages_query.status_code, 200)
|
||||
message_request_ids = {str(row.get("request_id")) for row in (messages_query.json().get("rows") or [])}
|
||||
self.assertEqual(message_request_ids, {own_id, unassigned_id})
|
||||
|
||||
attachments_query = self.client.post(
|
||||
"/api/admin/crud/attachments/query",
|
||||
headers=headers,
|
||||
json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}},
|
||||
)
|
||||
self.assertEqual(attachments_query.status_code, 200)
|
||||
attachment_request_ids = {str(row.get("request_id")) for row in (attachments_query.json().get("rows") or [])}
|
||||
self.assertEqual(attachment_request_ids, {own_id, unassigned_id})
|
||||
|
||||
foreign_message_get = self.client.get(f"/api/admin/crud/messages/{foreign_msg_id}", headers=headers)
|
||||
self.assertEqual(foreign_message_get.status_code, 403)
|
||||
foreign_attachment_get = self.client.get(f"/api/admin/crud/attachments/{foreign_att_id}", headers=headers)
|
||||
self.assertEqual(foreign_attachment_get.status_code, 403)
|
||||
|
||||
created_message = self.client.post(
|
||||
"/api/admin/crud/messages",
|
||||
headers=headers,
|
||||
json={"request_id": own_id, "body": "Ответ юриста"},
|
||||
)
|
||||
self.assertEqual(created_message.status_code, 201)
|
||||
self.assertEqual(created_message.json().get("author_type"), "LAWYER")
|
||||
self.assertEqual(created_message.json().get("request_id"), own_id)
|
||||
|
||||
blocked_unassigned_create = self.client.post(
|
||||
"/api/admin/crud/messages",
|
||||
headers=headers,
|
||||
json={"request_id": unassigned_id, "body": "Попытка без назначения"},
|
||||
)
|
||||
self.assertEqual(blocked_unassigned_create.status_code, 403)
|
||||
|
||||
def test_topic_status_flow_supports_branching_transitions(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
|
|
@ -394,6 +590,95 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(second_branch.status_code, 200)
|
||||
|
||||
def test_admin_chat_service_endpoints_follow_rbac(self):
|
||||
with self.SessionLocal() as db:
|
||||
lawyer_self = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Чат Свой",
|
||||
email="lawyer.chat.self@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
lawyer_other = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Чат Чужой",
|
||||
email="lawyer.chat.other@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([lawyer_self, lawyer_other])
|
||||
db.flush()
|
||||
self_id = str(lawyer_self.id)
|
||||
other_id = str(lawyer_other.id)
|
||||
|
||||
own = Request(
|
||||
track_number="TRK-CHAT-ADMIN-OWN",
|
||||
client_name="Клиент Свой",
|
||||
client_phone="+79990030001",
|
||||
status_code="IN_PROGRESS",
|
||||
description="own",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=self_id,
|
||||
)
|
||||
foreign = Request(
|
||||
track_number="TRK-CHAT-ADMIN-FOREIGN",
|
||||
client_name="Клиент Чужой",
|
||||
client_phone="+79990030002",
|
||||
status_code="IN_PROGRESS",
|
||||
description="foreign",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=other_id,
|
||||
)
|
||||
unassigned = Request(
|
||||
track_number="TRK-CHAT-ADMIN-UNASSIGNED",
|
||||
client_name="Клиент Без назначения",
|
||||
client_phone="+79990030003",
|
||||
status_code="NEW",
|
||||
description="unassigned",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=None,
|
||||
)
|
||||
db.add_all([own, foreign, unassigned])
|
||||
db.flush()
|
||||
db.add(Message(request_id=own.id, author_type="CLIENT", author_name="Клиент", body="start"))
|
||||
db.commit()
|
||||
own_id = str(own.id)
|
||||
foreign_id = str(foreign.id)
|
||||
unassigned_id = str(unassigned.id)
|
||||
|
||||
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.chat.self@example.com", sub=self_id)
|
||||
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
|
||||
own_list = self.client.get(f"/api/admin/chat/requests/{own_id}/messages", headers=lawyer_headers)
|
||||
self.assertEqual(own_list.status_code, 200)
|
||||
self.assertEqual(own_list.json()["total"], 1)
|
||||
|
||||
foreign_list = self.client.get(f"/api/admin/chat/requests/{foreign_id}/messages", headers=lawyer_headers)
|
||||
self.assertEqual(foreign_list.status_code, 403)
|
||||
|
||||
own_create = self.client.post(
|
||||
f"/api/admin/chat/requests/{own_id}/messages",
|
||||
headers=lawyer_headers,
|
||||
json={"body": "Ответ из chat service"},
|
||||
)
|
||||
self.assertEqual(own_create.status_code, 201)
|
||||
self.assertEqual(own_create.json()["author_type"], "LAWYER")
|
||||
|
||||
unassigned_create = self.client.post(
|
||||
f"/api/admin/chat/requests/{unassigned_id}/messages",
|
||||
headers=lawyer_headers,
|
||||
json={"body": "Нельзя в неназначенную"},
|
||||
)
|
||||
self.assertEqual(unassigned_create.status_code, 403)
|
||||
|
||||
admin_create = self.client.post(
|
||||
f"/api/admin/chat/requests/{foreign_id}/messages",
|
||||
headers=admin_headers,
|
||||
json={"body": "Сообщение администратора"},
|
||||
)
|
||||
self.assertEqual(admin_create.status_code, 201)
|
||||
self.assertEqual(admin_create.json()["author_type"], "SYSTEM")
|
||||
|
||||
def test_request_read_markers_status_update_and_lawyer_open_reset(self):
|
||||
with self.SessionLocal() as db:
|
||||
lawyer = AdminUser(
|
||||
|
|
@ -688,6 +973,100 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
self.assertEqual(history[0].from_status, "NEW")
|
||||
self.assertEqual(history[0].to_status, "IN_PROGRESS")
|
||||
|
||||
def test_request_status_route_returns_progress_and_respects_role_scope(self):
|
||||
with self.SessionLocal() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Status(code="NEW", name="Новая", enabled=True, sort_order=1, kind="DEFAULT"),
|
||||
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=2, kind="DEFAULT"),
|
||||
Status(code="WAITING_CLIENT", name="Ожидание клиента", enabled=True, sort_order=3, kind="DEFAULT"),
|
||||
]
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
TopicStatusTransition(
|
||||
topic_code="civil-law",
|
||||
from_status="NEW",
|
||||
to_status="IN_PROGRESS",
|
||||
enabled=True,
|
||||
sla_hours=24,
|
||||
sort_order=1,
|
||||
),
|
||||
TopicStatusTransition(
|
||||
topic_code="civil-law",
|
||||
from_status="IN_PROGRESS",
|
||||
to_status="WAITING_CLIENT",
|
||||
enabled=True,
|
||||
sla_hours=72,
|
||||
sort_order=2,
|
||||
),
|
||||
]
|
||||
)
|
||||
lawyer = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист маршрута",
|
||||
email="lawyer.route@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
outsider = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Чужой юрист",
|
||||
email="lawyer.outside.route@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([lawyer, outsider])
|
||||
db.flush()
|
||||
req = Request(
|
||||
track_number="TRK-ROUTE-1",
|
||||
client_name="Клиент",
|
||||
client_phone="+79990001122",
|
||||
topic_code="civil-law",
|
||||
status_code="IN_PROGRESS",
|
||||
assigned_lawyer_id=str(lawyer.id),
|
||||
description="route check",
|
||||
extra_fields={},
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
db.add(
|
||||
StatusHistory(
|
||||
request_id=req.id,
|
||||
from_status="NEW",
|
||||
to_status="IN_PROGRESS",
|
||||
comment="start progress",
|
||||
changed_by_admin_id=None,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
request_id = str(req.id)
|
||||
lawyer_id = str(lawyer.id)
|
||||
outsider_id = str(outsider.id)
|
||||
|
||||
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
assigned_headers = self._auth_headers("LAWYER", email="lawyer.route@example.com", sub=lawyer_id)
|
||||
outsider_headers = self._auth_headers("LAWYER", email="lawyer.outside.route@example.com", sub=outsider_id)
|
||||
|
||||
admin_response = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=admin_headers)
|
||||
self.assertEqual(admin_response.status_code, 200)
|
||||
payload = admin_response.json()
|
||||
self.assertEqual(payload["current_status"], "IN_PROGRESS")
|
||||
nodes = payload.get("nodes") or []
|
||||
self.assertEqual([item["code"] for item in nodes], ["NEW", "IN_PROGRESS", "WAITING_CLIENT"])
|
||||
self.assertEqual(nodes[0]["state"], "completed")
|
||||
self.assertEqual(nodes[1]["state"], "current")
|
||||
self.assertEqual(nodes[2]["state"], "pending")
|
||||
self.assertEqual(nodes[1]["sla_hours"], 24)
|
||||
self.assertEqual(nodes[2]["sla_hours"], 72)
|
||||
|
||||
assigned_response = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=assigned_headers)
|
||||
self.assertEqual(assigned_response.status_code, 200)
|
||||
self.assertEqual(assigned_response.json()["current_status"], "IN_PROGRESS")
|
||||
|
||||
outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=outsider_headers)
|
||||
self.assertEqual(outsider_forbidden.status_code, 403)
|
||||
|
||||
def test_lawyer_can_claim_unassigned_request_and_takeover_is_forbidden(self):
|
||||
with self.SessionLocal() as db:
|
||||
lawyer1 = AdminUser(
|
||||
|
|
@ -936,6 +1315,64 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
|||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("Неизвестные поля", response.json().get("detail", ""))
|
||||
|
||||
def test_calculated_fields_are_read_only_for_universal_crud(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
|
||||
blocked_create = self.client.post(
|
||||
"/api/admin/crud/requests",
|
||||
headers=headers,
|
||||
json={
|
||||
"client_name": "Клиент readonly",
|
||||
"client_phone": "+79995550011",
|
||||
"status_code": "NEW",
|
||||
"description": "calc readonly",
|
||||
"invoice_amount": 12500,
|
||||
},
|
||||
)
|
||||
self.assertEqual(blocked_create.status_code, 400)
|
||||
self.assertIn("Неизвестные поля", blocked_create.json().get("detail", ""))
|
||||
|
||||
created = self.client.post(
|
||||
"/api/admin/crud/requests",
|
||||
headers=headers,
|
||||
json={
|
||||
"client_name": "Клиент readonly",
|
||||
"client_phone": "+79995550012",
|
||||
"status_code": "NEW",
|
||||
"description": "valid create",
|
||||
},
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
request_id = created.json()["id"]
|
||||
|
||||
blocked_patch = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"paid_at": "2026-02-24T12:00:00+03:00"},
|
||||
)
|
||||
self.assertEqual(blocked_patch.status_code, 400)
|
||||
self.assertIn("Неизвестные поля", blocked_patch.json().get("detail", ""))
|
||||
|
||||
meta_response = self.client.get("/api/admin/crud/meta/tables", headers=headers)
|
||||
self.assertEqual(meta_response.status_code, 200)
|
||||
by_table = {row["table"]: row for row in (meta_response.json().get("tables") or [])}
|
||||
|
||||
request_columns = {col["name"]: col for col in (by_table.get("requests", {}).get("columns") or [])}
|
||||
self.assertIn("invoice_amount", request_columns)
|
||||
self.assertIn("paid_at", request_columns)
|
||||
self.assertIn("paid_by_admin_id", request_columns)
|
||||
self.assertIn("total_attachments_bytes", request_columns)
|
||||
self.assertFalse(request_columns["invoice_amount"]["editable"])
|
||||
self.assertFalse(request_columns["paid_at"]["editable"])
|
||||
self.assertFalse(request_columns["paid_by_admin_id"]["editable"])
|
||||
self.assertFalse(request_columns["total_attachments_bytes"]["editable"])
|
||||
|
||||
invoice_columns = {col["name"]: col for col in (by_table.get("invoices", {}).get("columns") or [])}
|
||||
self.assertIn("issued_at", invoice_columns)
|
||||
self.assertIn("paid_at", invoice_columns)
|
||||
self.assertFalse(invoice_columns["issued_at"]["editable"])
|
||||
self.assertFalse(invoice_columns["paid_at"]["editable"])
|
||||
|
||||
def test_topic_code_is_autogenerated_when_missing(self):
|
||||
headers = self._auth_headers("ADMIN")
|
||||
first = self.client.post(
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ class MigrationTests(unittest.TestCase):
|
|||
def test_upgrade_head_creates_expected_tables(self):
|
||||
expected = {
|
||||
"admin_users",
|
||||
"clients",
|
||||
"table_availability",
|
||||
"topics",
|
||||
"statuses",
|
||||
"form_fields",
|
||||
|
|
@ -106,11 +108,13 @@ class MigrationTests(unittest.TestCase):
|
|||
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, "0014_security_audit_log")
|
||||
self.assertEqual(version, "0016_table_availability")
|
||||
|
||||
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||
tables = {
|
||||
"admin_users",
|
||||
"clients",
|
||||
"table_availability",
|
||||
"topics",
|
||||
"statuses",
|
||||
"form_fields",
|
||||
|
|
@ -171,6 +175,7 @@ class MigrationTests(unittest.TestCase):
|
|||
|
||||
def test_requests_contains_financial_columns(self):
|
||||
columns = {column["name"] for column in self.inspector.get_columns("requests")}
|
||||
self.assertIn("client_id", columns)
|
||||
self.assertIn("effective_rate", columns)
|
||||
self.assertIn("invoice_amount", columns)
|
||||
self.assertIn("paid_at", columns)
|
||||
|
|
@ -178,6 +183,7 @@ class MigrationTests(unittest.TestCase):
|
|||
|
||||
def test_invoices_contains_core_columns(self):
|
||||
columns = {column["name"] for column in self.inspector.get_columns("invoices")}
|
||||
self.assertIn("client_id", columns)
|
||||
self.assertIn("request_id", columns)
|
||||
self.assertIn("invoice_number", columns)
|
||||
self.assertIn("status", columns)
|
||||
|
|
@ -194,3 +200,11 @@ class MigrationTests(unittest.TestCase):
|
|||
columns = {column["name"] for column in self.inspector.get_columns("statuses")}
|
||||
self.assertIn("kind", columns)
|
||||
self.assertIn("invoice_template", columns)
|
||||
|
||||
def test_clients_contains_core_columns(self):
|
||||
columns = {column["name"] for column in self.inspector.get_columns("clients")}
|
||||
self.assertIn("id", columns)
|
||||
self.assertIn("full_name", columns)
|
||||
self.assertIn("phone", columns)
|
||||
self.assertIn("created_at", columns)
|
||||
self.assertIn("responsible", columns)
|
||||
|
|
|
|||
|
|
@ -200,6 +200,37 @@ class PublicCabinetTests(unittest.TestCase):
|
|||
self.assertTrue(req.lawyer_has_unread_updates)
|
||||
self.assertEqual(req.lawyer_unread_event_type, "MESSAGE")
|
||||
|
||||
def test_public_chat_service_endpoints_work_for_authorized_client(self):
|
||||
with self.SessionLocal() as db:
|
||||
req = Request(
|
||||
track_number="TRK-CHAT-001",
|
||||
client_name="Клиент Чат",
|
||||
client_phone="+79997770000",
|
||||
topic_code="consulting",
|
||||
status_code="NEW",
|
||||
description="Проверка chat service",
|
||||
extra_fields={},
|
||||
)
|
||||
db.add(req)
|
||||
db.commit()
|
||||
|
||||
cookies = self._public_cookies("TRK-CHAT-001")
|
||||
created = self.client.post(
|
||||
"/api/public/chat/requests/TRK-CHAT-001/messages",
|
||||
cookies=cookies,
|
||||
json={"body": "Сообщение через выделенный сервис"},
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
self.assertEqual(created.json()["author_type"], "CLIENT")
|
||||
|
||||
listed = self.client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=cookies)
|
||||
self.assertEqual(listed.status_code, 200)
|
||||
self.assertEqual(len(listed.json()), 1)
|
||||
self.assertIn("выделенный сервис", listed.json()[0]["body"])
|
||||
|
||||
denied = self.client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER"))
|
||||
self.assertEqual(denied.status_code, 403)
|
||||
|
||||
def test_public_cabinet_respects_track_access(self):
|
||||
with self.SessionLocal() as db:
|
||||
req = Request(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from app.main import app
|
|||
from app.core.config import settings
|
||||
from app.core.security import create_jwt, decode_jwt
|
||||
from app.db.session import get_db
|
||||
from app.models.client import Client
|
||||
from app.models.notification import Notification
|
||||
from app.models.otp_session import OtpSession
|
||||
from app.models.request import Request
|
||||
|
|
@ -36,6 +37,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
poolclass=StaticPool,
|
||||
)
|
||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||
Client.__table__.create(bind=cls.engine)
|
||||
Request.__table__.create(bind=cls.engine)
|
||||
Notification.__table__.create(bind=cls.engine)
|
||||
OtpSession.__table__.create(bind=cls.engine)
|
||||
|
|
@ -47,6 +49,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
OtpSession.__table__.drop(bind=cls.engine)
|
||||
TopicRequiredField.__table__.drop(bind=cls.engine)
|
||||
Request.__table__.drop(bind=cls.engine)
|
||||
Client.__table__.drop(bind=cls.engine)
|
||||
cls.engine.dispose()
|
||||
|
||||
def setUp(self):
|
||||
|
|
@ -55,6 +58,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
db.execute(delete(OtpSession))
|
||||
db.execute(delete(TopicRequiredField))
|
||||
db.execute(delete(Request))
|
||||
db.execute(delete(Client))
|
||||
db.commit()
|
||||
|
||||
def override_get_db():
|
||||
|
|
@ -114,12 +118,16 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
self.assertIsNotNone(created)
|
||||
self.assertEqual(created.client_name, payload["client_name"])
|
||||
self.assertEqual(created.client_phone, payload["client_phone"])
|
||||
self.assertIsNotNone(created.client_id)
|
||||
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"])
|
||||
self.assertEqual(created.responsible, "Клиент")
|
||||
client = db.get(Client, created.client_id)
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.phone, payload["client_phone"])
|
||||
|
||||
# After creation, cookie is switched to VIEW_REQUEST for this track.
|
||||
read = self.client.get(f"/api/public/requests/{body['track_number']}")
|
||||
|
|
@ -170,6 +178,73 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
|
||||
self.assertEqual(denied_other_track.status_code, 403)
|
||||
|
||||
def test_view_request_can_use_phone_otp_and_switch_between_client_requests(self):
|
||||
phone = "+79996660077"
|
||||
with self.SessionLocal() as db:
|
||||
client = Client(full_name="Клиент Мульти", phone=phone, responsible="seed")
|
||||
db.add(client)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
Request(
|
||||
track_number="TRK-MULTI-1",
|
||||
client_id=client.id,
|
||||
client_name=client.full_name,
|
||||
client_phone=client.phone,
|
||||
topic_code="consulting",
|
||||
status_code="NEW",
|
||||
description="Первая",
|
||||
extra_fields={},
|
||||
),
|
||||
Request(
|
||||
track_number="TRK-MULTI-2",
|
||||
client_id=client.id,
|
||||
client_name=client.full_name,
|
||||
client_phone=client.phone,
|
||||
topic_code="consulting",
|
||||
status_code="IN_PROGRESS",
|
||||
description="Вторая",
|
||||
extra_fields={},
|
||||
),
|
||||
Request(
|
||||
track_number="TRK-FOREIGN-1",
|
||||
client_name="Другой клиент",
|
||||
client_phone="+79990009999",
|
||||
topic_code="consulting",
|
||||
status_code="NEW",
|
||||
description="Чужая",
|
||||
extra_fields={},
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
with patch("app.api.public.otp._generate_code", return_value="111111"):
|
||||
sent = self.client.post(
|
||||
"/api/public/otp/send",
|
||||
json={"purpose": "VIEW_REQUEST", "client_phone": phone},
|
||||
)
|
||||
self.assertEqual(sent.status_code, 200)
|
||||
|
||||
verified = self.client.post(
|
||||
"/api/public/otp/verify",
|
||||
json={"purpose": "VIEW_REQUEST", "client_phone": phone, "code": "111111"},
|
||||
)
|
||||
self.assertEqual(verified.status_code, 200)
|
||||
|
||||
list_resp = self.client.get("/api/public/requests/my")
|
||||
self.assertEqual(list_resp.status_code, 200)
|
||||
rows = list_resp.json().get("rows") or []
|
||||
tracks = {row["track_number"] for row in rows}
|
||||
self.assertEqual(tracks, {"TRK-MULTI-1", "TRK-MULTI-2"})
|
||||
|
||||
opened = self.client.get("/api/public/requests/TRK-MULTI-2")
|
||||
self.assertEqual(opened.status_code, 200)
|
||||
self.assertEqual(opened.json()["track_number"], "TRK-MULTI-2")
|
||||
|
||||
denied = self.client.get("/api/public/requests/TRK-FOREIGN-1")
|
||||
self.assertEqual(denied.status_code, 403)
|
||||
|
||||
def test_open_request_marks_client_updates_as_read(self):
|
||||
with self.SessionLocal() as db:
|
||||
row = Request(
|
||||
|
|
@ -274,3 +349,38 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
self.assertIn(f"{settings.PUBLIC_COOKIE_NAME}=", cookie_header)
|
||||
self.assertIn(f"Max-Age={settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600}", cookie_header)
|
||||
self.assertIn("httponly", cookie_header.lower())
|
||||
|
||||
def test_verify_view_otp_by_phone_sets_view_session_subject_as_phone(self):
|
||||
phone = "+79998887766"
|
||||
with self.SessionLocal() as db:
|
||||
db.add(
|
||||
Request(
|
||||
track_number="TRK-VIEW-PHONE-1",
|
||||
client_name="Телефонный клиент",
|
||||
client_phone=phone,
|
||||
topic_code="consulting",
|
||||
status_code="NEW",
|
||||
description="Проверка",
|
||||
extra_fields={},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
with patch("app.api.public.otp._generate_code", return_value="222222"):
|
||||
sent = self.client.post(
|
||||
"/api/public/otp/send",
|
||||
json={"purpose": "VIEW_REQUEST", "client_phone": phone},
|
||||
)
|
||||
self.assertEqual(sent.status_code, 200)
|
||||
|
||||
verified = self.client.post(
|
||||
"/api/public/otp/verify",
|
||||
json={"purpose": "VIEW_REQUEST", "client_phone": phone, "code": "222222"},
|
||||
)
|
||||
self.assertEqual(verified.status_code, 200)
|
||||
|
||||
token = verified.cookies.get(settings.PUBLIC_COOKIE_NAME)
|
||||
self.assertTrue(token)
|
||||
payload = decode_jwt(token, settings.PUBLIC_JWT_SECRET)
|
||||
self.assertEqual(payload.get("sub"), phone)
|
||||
self.assertEqual(payload.get("purpose"), "VIEW_REQUEST")
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from app.main import app
|
|||
from app.models.admin_user import AdminUser
|
||||
from app.models.admin_user_topic import AdminUserTopic
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.client import Client
|
||||
from app.models.notification import Notification
|
||||
from app.models.request import Request
|
||||
from app.models.status import Status
|
||||
|
|
@ -40,6 +41,7 @@ class RequestRatesTests(unittest.TestCase):
|
|||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||
AdminUser.__table__.create(bind=cls.engine)
|
||||
AdminUserTopic.__table__.create(bind=cls.engine)
|
||||
Client.__table__.create(bind=cls.engine)
|
||||
Request.__table__.create(bind=cls.engine)
|
||||
Status.__table__.create(bind=cls.engine)
|
||||
TopicRequiredField.__table__.create(bind=cls.engine)
|
||||
|
|
@ -57,6 +59,7 @@ class RequestRatesTests(unittest.TestCase):
|
|||
TopicRequiredField.__table__.drop(bind=cls.engine)
|
||||
Status.__table__.drop(bind=cls.engine)
|
||||
Request.__table__.drop(bind=cls.engine)
|
||||
Client.__table__.drop(bind=cls.engine)
|
||||
AdminUserTopic.__table__.drop(bind=cls.engine)
|
||||
AdminUser.__table__.drop(bind=cls.engine)
|
||||
cls.engine.dispose()
|
||||
|
|
@ -68,6 +71,7 @@ class RequestRatesTests(unittest.TestCase):
|
|||
db.execute(delete(TopicRequiredField))
|
||||
db.execute(delete(Status))
|
||||
db.execute(delete(Request))
|
||||
db.execute(delete(Client))
|
||||
db.execute(delete(AdminUserTopic))
|
||||
db.execute(delete(AdminUser))
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -232,6 +232,44 @@ class UploadsS3Tests(unittest.TestCase):
|
|||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0].s3_key, key)
|
||||
|
||||
def test_public_attachment_object_preview_returns_inline_response(self):
|
||||
fake_s3 = _FakeS3Storage()
|
||||
with self.SessionLocal() as db:
|
||||
req = Request(
|
||||
track_number="TRK-PUB-PREVIEW",
|
||||
client_name="Клиент",
|
||||
client_phone="+79994443322",
|
||||
topic_code="civil-law",
|
||||
status_code="IN_PROGRESS",
|
||||
extra_fields={},
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
key = f"requests/{req.id}/preview.pdf"
|
||||
attachment = Attachment(
|
||||
request_id=req.id,
|
||||
file_name="preview.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=1280,
|
||||
s3_key=key,
|
||||
)
|
||||
db.add(attachment)
|
||||
db.commit()
|
||||
attachment_id = str(attachment.id)
|
||||
track = req.track_number
|
||||
|
||||
fake_s3.objects[key] = {"size": 1280, "mime": "application/pdf", "content": b"pdf-preview"}
|
||||
public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
|
||||
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
|
||||
|
||||
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
|
||||
response = self.client.get(f"/api/public/uploads/object/{attachment_id}", cookies=cookies)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content, b"pdf-preview")
|
||||
self.assertIn("application/pdf", response.headers.get("content-type", ""))
|
||||
self.assertIn("inline;", response.headers.get("content-disposition", ""))
|
||||
|
||||
def test_admin_request_attachment_upload_sets_client_unread_marker(self):
|
||||
fake_s3 = _FakeS3Storage()
|
||||
with self.SessionLocal() as db:
|
||||
|
|
|
|||
Loading…
Reference in a new issue