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.security import create_jwt, verify_password
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import get_db
|
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 = APIRouter()
|
||||||
|
|
||||||
@router.post("/login", response_model=AdminToken)
|
@router.post("/login", response_model=AdminToken)
|
||||||
def login(payload: AdminLogin, db: Session = Depends(get_db)):
|
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):
|
if not user or not verify_password(payload.password, user.password_hash):
|
||||||
raise HTTPException(status_code=401, detail="Неверный логин или пароль")
|
raise HTTPException(status_code=401, detail="Неверный логин или пароль")
|
||||||
token = create_jwt({"sub": str(user.id), "email": user.email, "role": user.role},
|
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 importlib
|
||||||
import pkgutil
|
import pkgutil
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.inspection import inspect as sa_inspect
|
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.admin_user import AdminUser
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.form_field import FormField
|
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.request_data_requirement import RequestDataRequirement
|
||||||
from app.models.attachment import Attachment
|
from app.models.attachment import Attachment
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
|
|
@ -66,6 +69,8 @@ SYSTEM_FIELDS = {
|
||||||
"lawyer_unread_event_type",
|
"lawyer_unread_event_type",
|
||||||
}
|
}
|
||||||
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
|
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"}
|
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
|
||||||
|
|
||||||
# Per-table RBAC: table -> role -> actions.
|
# Per-table RBAC: table -> role -> actions.
|
||||||
|
|
@ -75,10 +80,20 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
|
||||||
"ADMIN": set(CRUD_ACTIONS),
|
"ADMIN": set(CRUD_ACTIONS),
|
||||||
"LAWYER": 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)},
|
"quotes": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"topics": {"ADMIN": set(CRUD_ACTIONS)},
|
"topics": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"statuses": {"ADMIN": set(CRUD_ACTIONS)},
|
"statuses": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"form_fields": {"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"}},
|
"audit_log": {"ADMIN": {"query", "read"}},
|
||||||
"security_audit_log": {"ADMIN": {"query", "read"}},
|
"security_audit_log": {"ADMIN": {"query", "read"}},
|
||||||
"otp_sessions": {"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="Юрист может работать только со своими назначенными заявками")
|
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:
|
def _serialize_value(value: Any) -> Any:
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {key: _serialize_value(val) for key, val in value.items()}
|
return {key: _serialize_value(val) for key, val in value.items()}
|
||||||
|
|
@ -231,6 +256,8 @@ def _table_label(table_name: str) -> str:
|
||||||
"topics": "Темы",
|
"topics": "Темы",
|
||||||
"statuses": "Статусы",
|
"statuses": "Статусы",
|
||||||
"form_fields": "Поля формы",
|
"form_fields": "Поля формы",
|
||||||
|
"clients": "Клиенты",
|
||||||
|
"table_availability": "Доступность таблиц",
|
||||||
"topic_required_fields": "Обязательные поля темы",
|
"topic_required_fields": "Обязательные поля темы",
|
||||||
"topic_data_templates": "Шаблоны данных темы",
|
"topic_data_templates": "Шаблоны данных темы",
|
||||||
"topic_status_transitions": "Переходы статусов темы",
|
"topic_status_transitions": "Переходы статусов темы",
|
||||||
|
|
@ -341,6 +368,7 @@ def _column_label(table_name: str, column_name: str) -> str:
|
||||||
"amount": "Сумма",
|
"amount": "Сумма",
|
||||||
"currency": "Валюта",
|
"currency": "Валюта",
|
||||||
"client_name": "Клиент",
|
"client_name": "Клиент",
|
||||||
|
"client_id": "Клиент (ID)",
|
||||||
"client_phone": "Телефон",
|
"client_phone": "Телефон",
|
||||||
"payer_display_name": "Плательщик",
|
"payer_display_name": "Плательщик",
|
||||||
"payer_details_encrypted": "Реквизиты (шифр.)",
|
"payer_details_encrypted": "Реквизиты (шифр.)",
|
||||||
|
|
@ -401,6 +429,7 @@ def _column_label(table_name: str, column_name: str) -> str:
|
||||||
"reason": "Причина",
|
"reason": "Причина",
|
||||||
"diff": "Изменения",
|
"diff": "Изменения",
|
||||||
"details": "Детали",
|
"details": "Детали",
|
||||||
|
"table_name": "Таблица",
|
||||||
}
|
}
|
||||||
if normalized_column in explicit:
|
if normalized_column in explicit:
|
||||||
return explicit[normalized_column]
|
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]:
|
def _protected_input_fields(table_name: str) -> set[str]:
|
||||||
if table_name == "admin_users":
|
if table_name == "admin_users":
|
||||||
return {"password_hash"}
|
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()
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -552,6 +585,52 @@ def _normalize_optional_string(value: Any) -> str | None:
|
||||||
return text or 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:
|
def _active_lawyer_or_400(db: Session, lawyer_id: Any) -> AdminUser:
|
||||||
lawyer_uuid = _parse_uuid_or_400(lawyer_id, "assigned_lawyer_id")
|
lawyer_uuid = _parse_uuid_or_400(lawyer_id, "assigned_lawyer_id")
|
||||||
lawyer = db.get(AdminUser, lawyer_uuid)
|
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 _table_section(table_name: str) -> str:
|
||||||
def list_tables_meta(admin: dict = Depends(get_current_admin)):
|
if table_name in {"requests", "invoices"}:
|
||||||
role = str(admin.get("role") or "").upper()
|
return "main"
|
||||||
if role != "ADMIN":
|
if table_name == "table_availability":
|
||||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
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()
|
table_models = _table_model_map()
|
||||||
|
availability = _table_availability_map(db)
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for table_name in sorted(table_models.keys()):
|
for table_name in sorted(table_models.keys()):
|
||||||
model = table_models[table_name]
|
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))
|
actions = sorted(_allowed_actions(role, table_name))
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"key": table_name,
|
"key": table_name,
|
||||||
"table": table_name,
|
"table": table_name,
|
||||||
"label": _table_label(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,
|
"actions": actions,
|
||||||
"query_endpoint": f"/api/admin/crud/{table_name}/query",
|
"query_endpoint": f"/api/admin/crud/{table_name}/query",
|
||||||
"create_endpoint": f"/api/admin/crud/{table_name}",
|
"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),
|
"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")
|
@router.post("/{table_name}/query")
|
||||||
|
|
@ -1003,6 +1180,22 @@ def query_table(
|
||||||
Request.assigned_lawyer_id.is_(None),
|
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)
|
query = apply_universal_query(base_query, model, uq)
|
||||||
total = query.count()
|
total = query.count()
|
||||||
rows = query.offset(uq.page.offset).limit(uq.page.limit).all()
|
rows = query.offset(uq.page.offset).limit(uq.page.limit).all()
|
||||||
|
|
@ -1039,6 +1232,12 @@ def get_row(
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(req)
|
db.refresh(req)
|
||||||
row = 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))
|
return _strip_hidden_fields(normalized, _row_to_dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1051,6 +1250,9 @@ def create_row(
|
||||||
):
|
):
|
||||||
normalized, model = _resolve_table_model(table_name)
|
normalized, model = _resolve_table_model(table_name)
|
||||||
_require_table_action(admin, normalized, "create")
|
_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):
|
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
|
||||||
assigned_lawyer_id = payload.get("assigned_lawyer_id")
|
assigned_lawyer_id = payload.get("assigned_lawyer_id")
|
||||||
if str(assigned_lawyer_id or "").strip():
|
if str(assigned_lawyer_id or "").strip():
|
||||||
|
|
@ -1060,8 +1262,28 @@ def create_row(
|
||||||
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||||||
|
|
||||||
prepared = _prepare_create_payload(normalized, payload)
|
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":
|
if normalized == "requests":
|
||||||
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
|
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):
|
if not _is_lawyer(admin):
|
||||||
assigned_raw = prepared.get("assigned_lawyer_id")
|
assigned_raw = prepared.get("assigned_lawyer_id")
|
||||||
if assigned_raw is None or not str(assigned_raw).strip():
|
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)
|
prepared["assigned_lawyer_id"] = str(assigned_lawyer.id)
|
||||||
if prepared.get("effective_rate") is None:
|
if prepared.get("effective_rate") is None:
|
||||||
prepared["effective_rate"] = assigned_lawyer.default_rate
|
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)
|
prepared = _apply_auto_fields_for_create(db, model, normalized, prepared)
|
||||||
clean_payload = _sanitize_payload(
|
clean_payload = _sanitize_payload(
|
||||||
model,
|
model,
|
||||||
|
|
@ -1092,8 +1318,12 @@ def create_row(
|
||||||
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||||
if normalized == "statuses":
|
if normalized == "statuses":
|
||||||
clean_payload = _apply_status_fields(clean_payload)
|
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):
|
if "responsible" in _columns_map(model):
|
||||||
clean_payload["responsible"] = _resolve_responsible(admin)
|
clean_payload["responsible"] = responsible
|
||||||
row = model(**clean_payload)
|
row = model(**clean_payload)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -1121,6 +1351,7 @@ def update_row(
|
||||||
):
|
):
|
||||||
normalized, model = _resolve_table_model(table_name)
|
normalized, model = _resolve_table_model(table_name)
|
||||||
_require_table_action(admin, normalized, "update")
|
_require_table_action(admin, normalized, "update")
|
||||||
|
responsible = _resolve_responsible(admin)
|
||||||
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
|
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
|
||||||
if "assigned_lawyer_id" in payload:
|
if "assigned_lawyer_id" in payload:
|
||||||
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
||||||
|
|
@ -1154,6 +1385,26 @@ def update_row(
|
||||||
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||||
if normalized == "statuses":
|
if normalized == "statuses":
|
||||||
clean_payload = _apply_status_fields(clean_payload)
|
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:
|
if normalized == "requests" and not _is_lawyer(admin) and "assigned_lawyer_id" in clean_payload:
|
||||||
assigned_raw = clean_payload.get("assigned_lawyer_id")
|
assigned_raw = clean_payload.get("assigned_lawyer_id")
|
||||||
if assigned_raw is None or not str(assigned_raw).strip():
|
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)
|
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:
|
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
|
clean_payload["effective_rate"] = assigned_lawyer.default_rate
|
||||||
|
if "responsible" in _columns_map(model):
|
||||||
|
clean_payload["responsible"] = responsible
|
||||||
before = _row_to_dict(row)
|
before = _row_to_dict(row)
|
||||||
if normalized == "topic_status_transitions":
|
if normalized == "topic_status_transitions":
|
||||||
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
|
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,
|
from_status=before_status,
|
||||||
to_status=after_status,
|
to_status=after_status,
|
||||||
admin=admin,
|
admin=admin,
|
||||||
responsible=_resolve_responsible(admin),
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
mark_unread_for_client(row, EVENT_STATUS)
|
mark_unread_for_client(row, EVENT_STATUS)
|
||||||
apply_status_change_effects(
|
apply_status_change_effects(
|
||||||
|
|
@ -1194,7 +1447,7 @@ def update_row(
|
||||||
from_status=before_status,
|
from_status=before_status,
|
||||||
to_status=after_status,
|
to_status=after_status,
|
||||||
admin=admin,
|
admin=admin,
|
||||||
responsible=_resolve_responsible(admin),
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
notify_request_event(
|
notify_request_event(
|
||||||
db,
|
db,
|
||||||
|
|
@ -1203,7 +1456,7 @@ def update_row(
|
||||||
actor_role=_actor_role(admin),
|
actor_role=_actor_role(admin),
|
||||||
actor_admin_user_id=admin.get("sub"),
|
actor_admin_user_id=admin.get("sub"),
|
||||||
body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")),
|
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():
|
for key, value in clean_payload.items():
|
||||||
setattr(row, key, value)
|
setattr(row, key, value)
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ def _serialize_invoice(
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"invoice_number": row.invoice_number,
|
"invoice_number": row.invoice_number,
|
||||||
"request_id": str(row.request_id),
|
"request_id": str(row.request_id),
|
||||||
|
"client_id": str(row.client_id) if row.client_id else None,
|
||||||
"request_track_number": request_track,
|
"request_track_number": request_track,
|
||||||
"status": row.status,
|
"status": row.status,
|
||||||
"status_label": STATUS_LABELS.get(str(row.status or "").upper(), row.status),
|
"status_label": STATUS_LABELS.get(str(row.status or "").upper(), row.status),
|
||||||
|
|
@ -275,6 +276,7 @@ def create_invoice(
|
||||||
|
|
||||||
invoice = Invoice(
|
invoice = Invoice(
|
||||||
request_id=req.id,
|
request_id=req.id,
|
||||||
|
client_id=req.client_id,
|
||||||
invoice_number=str(payload.get("invoice_number") or "").strip() or _invoice_number(db),
|
invoice_number=str(payload.get("invoice_number") or "").strip() or _invoice_number(db),
|
||||||
status=status,
|
status=status,
|
||||||
amount=_amount_or_400(payload.get("amount")),
|
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.audit_log import AuditLog
|
||||||
from app.models.request_data_requirement import RequestDataRequirement
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
from app.models.request import Request
|
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_data_template import TopicDataTemplate
|
||||||
|
from app.models.topic_status_transition import TopicStatusTransition
|
||||||
from app.services.notifications import (
|
from app.services.notifications import (
|
||||||
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||||||
mark_admin_notifications_read,
|
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")
|
@router.post("/{request_id}/claim")
|
||||||
def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("LAWYER"))):
|
def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("LAWYER"))):
|
||||||
request_uuid = _request_uuid_or_400(request_id)
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter
|
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 = APIRouter()
|
||||||
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
|
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(metrics.router, prefix="/metrics", tags=["AdminMetrics"])
|
||||||
router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"])
|
router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"])
|
||||||
router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"])
|
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"])
|
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')
|
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
|
||||||
else:
|
else:
|
||||||
track_number = _normalize_track(payload.track_number)
|
track_number = _normalize_track(payload.track_number)
|
||||||
if not track_number:
|
phone = _normalize_phone(payload.client_phone)
|
||||||
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
if track_number:
|
||||||
request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first()
|
request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first()
|
||||||
if request_row is None:
|
if request_row is None:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
phone = _normalize_phone(request_row.client_phone)
|
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:
|
if not phone:
|
||||||
raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона")
|
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')
|
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
|
||||||
else:
|
else:
|
||||||
track_number = _normalize_track(payload.track_number)
|
track_number = _normalize_track(payload.track_number)
|
||||||
if not track_number:
|
phone = _normalize_phone(payload.client_phone)
|
||||||
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
if not track_number and not phone:
|
||||||
|
raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"')
|
||||||
|
|
||||||
_rate_limit_or_429(
|
_rate_limit_or_429(
|
||||||
"verify",
|
"verify",
|
||||||
|
|
@ -212,11 +219,10 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses
|
||||||
track_number=track_number,
|
track_number=track_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
query = db.query(OtpSession).filter(
|
query = db.query(OtpSession).filter(OtpSession.purpose == purpose)
|
||||||
OtpSession.purpose == purpose,
|
if track_number is not None and track_number != "":
|
||||||
OtpSession.track_number == track_number,
|
query = query.filter(OtpSession.track_number == track_number)
|
||||||
)
|
if phone is not None and phone != "":
|
||||||
if phone is not None:
|
|
||||||
query = query.filter(OtpSession.phone == phone)
|
query = query.filter(OtpSession.phone == phone)
|
||||||
|
|
||||||
row = query.order_by(OtpSession.created_at.desc()).first()
|
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()
|
db.commit()
|
||||||
raise HTTPException(status_code=400, detail="Неверный OTP-код")
|
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:
|
if not subject:
|
||||||
raise HTTPException(status_code=400, detail="Некорректная OTP-сессия")
|
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.db.session import get_db
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
from app.models.attachment import Attachment
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.client import Client
|
||||||
from app.models.invoice import Invoice
|
from app.models.invoice import Invoice
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status_history import StatusHistory
|
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_crypto import decrypt_requisites
|
||||||
from app.services.invoice_pdf import build_invoice_pdf_bytes
|
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 (
|
from app.services.notifications import (
|
||||||
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
|
|
||||||
get_client_notification,
|
get_client_notification,
|
||||||
list_client_notifications,
|
list_client_notifications,
|
||||||
mark_client_notifications_read,
|
mark_client_notifications_read,
|
||||||
notify_request_event,
|
|
||||||
serialize_notification,
|
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.services.request_templates import validate_required_topic_fields_or_400
|
||||||
from app.schemas.public import (
|
from app.schemas.public import (
|
||||||
PublicAttachmentRead,
|
PublicAttachmentRead,
|
||||||
|
|
@ -52,16 +53,20 @@ INVOICE_STATUS_LABELS = {
|
||||||
|
|
||||||
|
|
||||||
def _normalize_phone(raw: str | None) -> str:
|
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:
|
def _normalize_track(raw: str | None) -> str:
|
||||||
return str(raw or "").strip().upper()
|
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(
|
token = create_jwt(
|
||||||
{"sub": track_number, "purpose": OTP_VIEW_PURPOSE},
|
{"sub": subject, "purpose": OTP_VIEW_PURPOSE},
|
||||||
settings.PUBLIC_JWT_SECRET,
|
settings.PUBLIC_JWT_SECRET,
|
||||||
timedelta(days=settings.PUBLIC_JWT_TTL_DAYS),
|
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")
|
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()
|
purpose = str(session.get("purpose") or "").strip().upper()
|
||||||
sub = _normalize_track(session.get("sub"))
|
subject = str(session.get("sub") or "").strip()
|
||||||
if purpose != OTP_VIEW_PURPOSE or not sub or sub != _normalize_track(track_number):
|
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="Нет доступа к заявке")
|
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||||
|
|
||||||
|
|
||||||
def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> Request:
|
def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> Request:
|
||||||
normalized_track = _normalize_track(track_number)
|
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()
|
req = db.query(Request).filter(Request.track_number == normalized_track).first()
|
||||||
if req is None:
|
if req is None:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_view_access_or_403(session, req)
|
||||||
return 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:
|
def _to_iso(value) -> str | None:
|
||||||
return value.isoformat() if value is not None else 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)
|
_require_create_session_or_403(session, payload.client_phone)
|
||||||
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
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()}"
|
track = f"TRK-{uuid4().hex[:10].upper()}"
|
||||||
row = Request(
|
row = Request(
|
||||||
track_number=track,
|
track_number=track,
|
||||||
client_name=payload.client_name,
|
client_id=client.id,
|
||||||
client_phone=payload.client_phone,
|
client_name=client.full_name,
|
||||||
|
client_phone=client.phone,
|
||||||
topic_code=payload.topic_code,
|
topic_code=payload.topic_code,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
extra_fields=payload.extra_fields,
|
extra_fields=payload.extra_fields,
|
||||||
|
|
@ -142,10 +187,55 @@ def create_request(
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
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)
|
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}")
|
@router.get("/{track_number}")
|
||||||
def get_request_by_track(
|
def get_request_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
|
@ -167,6 +257,7 @@ def get_request_by_track(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(req.id),
|
"id": str(req.id),
|
||||||
|
"client_id": str(req.client_id) if req.client_id else None,
|
||||||
"track_number": req.track_number,
|
"track_number": req.track_number,
|
||||||
"client_name": req.client_name,
|
"client_name": req.client_name,
|
||||||
"client_phone": req.client_phone,
|
"client_phone": req.client_phone,
|
||||||
|
|
@ -191,12 +282,7 @@ def list_messages_by_track(
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
req = _request_for_track_or_404(db, session, track_number)
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
rows = (
|
rows = list_messages_for_request(db, req.id)
|
||||||
db.query(Message)
|
|
||||||
.filter(Message.request_id == req.id)
|
|
||||||
.order_by(Message.created_at.asc(), Message.id.asc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
PublicMessageRead(
|
PublicMessageRead(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
|
|
@ -219,31 +305,7 @@ def create_message_by_track(
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
req = _request_for_track_or_404(db, session, track_number)
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
body = str(payload.body or "").strip()
|
row = create_client_message(db, request=req, body=payload.body)
|
||||||
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)
|
|
||||||
|
|
||||||
return PublicMessageRead(
|
return PublicMessageRead(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
from fastapi import APIRouter
|
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 = APIRouter()
|
||||||
router.include_router(requests.router, prefix="/requests", tags=["Public"])
|
router.include_router(requests.router, prefix="/requests", tags=["Public"])
|
||||||
router.include_router(otp.router, prefix="/otp", tags=["Public"])
|
router.include_router(otp.router, prefix="/otp", tags=["Public"])
|
||||||
router.include_router(quotes.router, prefix="/quotes", tags=["Public"])
|
router.include_router(quotes.router, prefix="/quotes", tags=["Public"])
|
||||||
router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"])
|
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()
|
purpose = str(session.get("purpose") or "").strip().upper()
|
||||||
if purpose != "VIEW_REQUEST":
|
if purpose != "VIEW_REQUEST":
|
||||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||||
track_from_session = str(session.get("sub") or "").strip()
|
subject = str(session.get("sub") or "").strip()
|
||||||
if not track_from_session or track_from_session != str(request.track_number):
|
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="Нет доступа к заявке")
|
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ class Settings(BaseSettings):
|
||||||
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
|
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
|
||||||
OTP_SEND_RATE_LIMIT: int = 8
|
OTP_SEND_RATE_LIMIT: int = 8
|
||||||
OTP_VERIFY_RATE_LIMIT: int = 20
|
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
|
@property
|
||||||
def cors_origins_list(self) -> List[str]:
|
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"
|
__tablename__ = "invoices"
|
||||||
|
|
||||||
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
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)
|
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")
|
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True, default="WAITING_PAYMENT")
|
||||||
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
|
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Integer, JSON, Numeric, String, Text
|
from sqlalchemy import Boolean, DateTime, Integer, JSON, Numeric, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
from app.models.common import UUIDMixin, TimestampMixin
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
@ -8,6 +10,7 @@ from app.models.common import UUIDMixin, TimestampMixin
|
||||||
class Request(Base, UUIDMixin, TimestampMixin):
|
class Request(Base, UUIDMixin, TimestampMixin):
|
||||||
__tablename__ = "requests"
|
__tablename__ = "requests"
|
||||||
track_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True)
|
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_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
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)
|
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;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
padding-left: 0.6rem;
|
padding-left: 0.6rem;
|
||||||
|
padding-right: 0.2rem;
|
||||||
border-left: 1px dashed rgba(212, 168, 106, 0.3);
|
border-left: 1px dashed rgba(212, 168, 106, 0.3);
|
||||||
margin: 0.2rem 0 0.1rem 0.2rem;
|
margin: 0.2rem 0 0.1rem 0.2rem;
|
||||||
|
max-height: 38vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-tree button {
|
.menu-tree button {
|
||||||
|
|
@ -210,6 +214,18 @@
|
||||||
font-size: 0.94rem;
|
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 {
|
.cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|
@ -607,6 +623,380 @@
|
||||||
word-break: break-word;
|
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 {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -688,6 +1078,9 @@
|
||||||
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.triple { grid-template-columns: 1fr; }
|
.triple { grid-template-columns: 1fr; }
|
||||||
.config-layout { 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) {
|
@media (max-width: 920px) {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Административная панель • Правовой трекер</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="admin-root"></div>
|
<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@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="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>
|
</body>
|
||||||
</html>
|
</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;
|
padding: 1.3rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel::before {
|
.panel::before {
|
||||||
|
|
@ -215,6 +217,21 @@
|
||||||
line-height: 1.35;
|
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 { padding: 1.3rem 0 2.2rem; }
|
||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
|
|
@ -313,14 +330,25 @@
|
||||||
.timeline h3 { margin: 0 0 0.35rem; font-size: 1rem; }
|
.timeline h3 { margin: 0 0 0.35rem; font-size: 1rem; }
|
||||||
.timeline p { margin: 0; color: var(--muted); line-height: 1.55; }
|
.timeline p { margin: 0; color: var(--muted); line-height: 1.55; }
|
||||||
|
|
||||||
.quote {
|
.approach-note {
|
||||||
border: 1px solid #4b5b71;
|
border: 1px solid #4b5b71;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: linear-gradient(160deg, #1e2b3c, #1a2432);
|
background: linear-gradient(160deg, #1e2b3c, #1a2432);
|
||||||
padding: 1rem;
|
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;
|
margin: 0;
|
||||||
min-height: 5.3rem;
|
min-height: 5.3rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|
@ -478,7 +506,7 @@
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, textarea {
|
input, textarea, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #3b4b5f;
|
border: 1px solid #3b4b5f;
|
||||||
|
|
@ -494,6 +522,15 @@
|
||||||
resize: vertical;
|
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 {
|
.form-foot {
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Аудиторы корпоративной безопасности</title>
|
<title>Аудиторы корпоративной безопасности</title>
|
||||||
<meta name="description" content="Юридический консалтинг и судебное сопровождение для сложных бизнес-ситуаций.">
|
<meta name="description" content="Юридический консалтинг и судебное сопровождение для сложных бизнес-ситуаций.">
|
||||||
<link rel="stylesheet" href="/landing.css" integrity="sha384-f2MyL8409LTp2ap3kS1Yf2FMNVyeypb/qY1jl7WtZpImICXE/fpZCqZZT6keMp50" crossorigin="anonymous">
|
<link rel="stylesheet" href="/landing.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|
@ -15,8 +15,7 @@
|
||||||
<a href="#practices">Компетенции</a>
|
<a href="#practices">Компетенции</a>
|
||||||
<a href="#approach">Подход</a>
|
<a href="#approach">Подход</a>
|
||||||
<a href="#expert">Эксперт</a>
|
<a href="#expert">Эксперт</a>
|
||||||
<a href="#cabinet">Кабинет клиента</a>
|
<button class="btn btn-ghost" type="button" data-open-access>Мои заявки</button>
|
||||||
<a href="/admin" class="btn btn-ghost">Админ-панель</a>
|
|
||||||
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
|
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32,6 +31,7 @@
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<button class="btn btn-primary" type="button" data-open-modal>Записаться на консультацию</button>
|
<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>
|
<a class="btn btn-ghost" href="#practices">Смотреть практики</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -53,6 +53,10 @@
|
||||||
<span>объем восстановленных прав</span>
|
<span>объем восстановленных прав</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="consultation-quote" aria-live="polite">
|
||||||
|
<p id="quote-text">Загрузка данных...</p>
|
||||||
|
<div class="quote-meta" id="quote-meta"></div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -113,10 +117,9 @@
|
||||||
<p>Сопровождаем исполнение решения, фиксируем сроки и контрольные точки, отчитываемся в понятном бизнес-формате.</p>
|
<p>Сопровождаем исполнение решения, фиксируем сроки и контрольные точки, отчитываемся в понятном бизнес-формате.</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="quote">
|
<article class="approach-note">
|
||||||
<small>Публичные цитаты</small>
|
<small>Принцип работы</small>
|
||||||
<p id="quote-text">Загрузка данных...</p>
|
<p>Каждую заявку ведем как проект: фиксируем цель, измеряем прогресс и заранее обозначаем ограничения по срокам и рискам.</p>
|
||||||
<div class="quote-meta" id="quote-meta"></div>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -144,81 +147,8 @@
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</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">
|
<section class="cta-band">
|
||||||
<p>
|
<p>Создайте заявку и получите номер обращения. По нему вы сможете отслеживать статус, чат и документы в отдельной странице клиента.</p>
|
||||||
Если вы пришли на сайт по рекомендации, укажите имя рекомендателя при отправке заявки.
|
|
||||||
Это поможет быстрее подготовиться к консультации и учесть контекст вашей ситуации.
|
|
||||||
</p>
|
|
||||||
<button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button>
|
<button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -246,12 +176,14 @@
|
||||||
<input id="phone" name="phone" type="tel" required placeholder="+7 (900) 000-00-00">
|
<input id="phone" name="phone" type="tel" required placeholder="+7 (900) 000-00-00">
|
||||||
</div>
|
</div>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="description">Описание задачи</label>
|
<label for="topic">Тема обращения</label>
|
||||||
<textarea id="description" name="description" placeholder="Кратко опишите ситуацию"></textarea>
|
<select id="topic" name="topic" required>
|
||||||
|
<option value="">Выберите тему</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="referral">Кто вас порекомендовал</label>
|
<label for="description">Описание задачи</label>
|
||||||
<input id="referral" name="referral" type="text" placeholder="Имя рекомендателя">
|
<textarea id="description" name="description" placeholder="Кратко опишите ситуацию"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-foot field full">
|
<div class="form-foot field full">
|
||||||
<button class="btn btn-primary" type="submit">Отправить заявку</button>
|
<button class="btn btn-primary" type="submit">Отправить заявку</button>
|
||||||
|
|
@ -261,6 +193,33 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,26 @@
|
||||||
(function () {
|
(function () {
|
||||||
const modal = document.getElementById("request-modal");
|
const requestModal = document.getElementById("request-modal");
|
||||||
const openButtons = document.querySelectorAll("[data-open-modal]");
|
const accessModal = document.getElementById("access-modal");
|
||||||
const closeButtons = document.querySelectorAll("[data-close-modal]");
|
const requestOpenButtons = document.querySelectorAll("[data-open-modal]");
|
||||||
const form = document.getElementById("request-form");
|
const requestCloseButtons = document.querySelectorAll("[data-close-modal]");
|
||||||
const status = document.getElementById("form-status");
|
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 quoteText = document.getElementById("quote-text");
|
||||||
const quoteMeta = document.getElementById("quote-meta");
|
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) {
|
function setStatus(el, message, kind) {
|
||||||
|
if (!el) return;
|
||||||
el.className = "status";
|
el.className = "status";
|
||||||
if (kind === "ok") el.classList.add("ok");
|
if (kind === "ok") el.classList.add("ok");
|
||||||
if (kind === "error") el.classList.add("error");
|
if (kind === "error") el.classList.add("error");
|
||||||
|
|
@ -81,140 +40,77 @@
|
||||||
return fallbackMessage;
|
return fallbackMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCabinetEnabled(enabled) {
|
function openModal(modal) {
|
||||||
cabinetChatBody.disabled = !enabled;
|
if (!modal) return;
|
||||||
cabinetChatSend.disabled = !enabled;
|
modal.classList.add("open");
|
||||||
cabinetFileInput.disabled = !enabled;
|
modal.setAttribute("aria-hidden", "false");
|
||||||
cabinetFileUpload.disabled = !enabled;
|
document.body.classList.add("modal-open");
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearList(node, emptyMessage) {
|
function closeModal(modal) {
|
||||||
node.innerHTML = "";
|
if (!modal) return;
|
||||||
const li = document.createElement("li");
|
modal.classList.remove("open");
|
||||||
li.className = "simple-item";
|
modal.setAttribute("aria-hidden", "true");
|
||||||
const p = document.createElement("p");
|
if (!document.querySelector(".modal-backdrop.open")) {
|
||||||
p.textContent = emptyMessage;
|
document.body.classList.remove("modal-open");
|
||||||
li.appendChild(p);
|
}
|
||||||
node.appendChild(li);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMessages(items) {
|
requestOpenButtons.forEach((button) => {
|
||||||
cabinetMessages.innerHTML = "";
|
button.addEventListener("click", () => openModal(requestModal));
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
requestCloseButtons.forEach((button) => {
|
||||||
|
button.addEventListener("click", () => closeModal(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);
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function renderInvoices(items) {
|
accessOpenButtons.forEach((button) => {
|
||||||
cabinetInvoices.innerHTML = "";
|
button.addEventListener("click", async () => {
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
try {
|
||||||
clearList(cabinetInvoices, "Счета пока не выставлены.");
|
const response = await fetch("/api/public/requests/my");
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = "/client.html";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
items.forEach((item) => {
|
} catch (_) {}
|
||||||
const li = document.createElement("li");
|
setStatus(accessStatus, "", null);
|
||||||
li.className = "simple-item";
|
openModal(accessModal);
|
||||||
|
});
|
||||||
const time = document.createElement("time");
|
});
|
||||||
time.textContent = "Сформирован: " + formatDate(item.issued_at);
|
accessCloseButtons.forEach((button) => {
|
||||||
li.appendChild(time);
|
button.addEventListener("click", () => closeModal(accessModal));
|
||||||
|
|
||||||
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) {
|
[requestModal, accessModal].forEach((modal) => {
|
||||||
cabinetTimeline.innerHTML = "";
|
if (!modal) return;
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
modal.addEventListener("click", (event) => {
|
||||||
clearList(cabinetTimeline, "История пока пуста.");
|
if (event.target === modal) closeModal(modal);
|
||||||
return;
|
});
|
||||||
}
|
});
|
||||||
items.forEach((item) => {
|
|
||||||
const li = document.createElement("li");
|
|
||||||
li.className = "simple-item";
|
|
||||||
|
|
||||||
const time = document.createElement("time");
|
document.addEventListener("keydown", (event) => {
|
||||||
time.textContent = formatDate(item.created_at);
|
if (event.key !== "Escape") return;
|
||||||
li.appendChild(time);
|
closeModal(requestModal);
|
||||||
|
closeModal(accessModal);
|
||||||
|
});
|
||||||
|
|
||||||
const p = document.createElement("p");
|
async function loadTopics() {
|
||||||
if (item.type === "status_change") {
|
if (!topicSelect) return;
|
||||||
p.textContent = "Статус: " + (item.payload?.from_status || "NEW") + " -> " + (item.payload?.to_status || "-");
|
const fallback = [{ code: "consulting", name: "Консультация" }];
|
||||||
} else if (item.type === "message") {
|
let topics = fallback;
|
||||||
const author = item.payload?.author_name || item.payload?.author_type || "Участник";
|
try {
|
||||||
p.textContent = "Сообщение от " + author + ": " + (item.payload?.body || "");
|
const response = await fetch("/api/public/requests/topics");
|
||||||
} else if (item.type === "attachment") {
|
const data = await parseJsonSafe(response);
|
||||||
p.textContent = "Файл: " + (item.payload?.file_name || "вложение");
|
if (response.ok && Array.isArray(data) && data.length > 0) {
|
||||||
} else {
|
topics = data;
|
||||||
p.textContent = "Событие";
|
|
||||||
}
|
}
|
||||||
li.appendChild(p);
|
} catch (_) {}
|
||||||
cabinetTimeline.appendChild(li);
|
|
||||||
|
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();
|
render();
|
||||||
if (items.length > 1) setInterval(render, 5500);
|
if (items.length > 1) setInterval(render, 5500);
|
||||||
} catch (error) {
|
} catch (_) {
|
||||||
quoteText.textContent = "С вами работает дружный коллектив профессионалов. Мы уверены в вашем успехе.";
|
quoteText.textContent = "С вами работает дружный коллектив профессионалов. Мы уверены в вашем успехе.";
|
||||||
quoteMeta.textContent = "Команда компании";
|
quoteMeta.textContent = "Команда компании";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRequestByTrack(trackNumber) {
|
accessSendOtpButton.addEventListener("click", async () => {
|
||||||
const response = await fetch("/api/public/requests/" + encodeURIComponent(trackNumber));
|
const phone = String(accessPhoneInput.value || "").trim();
|
||||||
const data = await parseJsonSafe(response);
|
if (!phone) {
|
||||||
return { response, data };
|
setStatus(accessStatus, "Введите номер телефона.", "error");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureViewAccess(trackNumber) {
|
try {
|
||||||
let { response, data } = await fetchRequestByTrack(trackNumber);
|
setStatus(accessStatus, "Отправляем OTP-код...", null);
|
||||||
if (response.ok) return data;
|
const response = await fetch("/api/public/otp/send", {
|
||||||
|
|
||||||
if (response.status !== 401 && response.status !== 403) {
|
|
||||||
throw new Error(apiErrorDetail(data, "Не удалось открыть заявку"));
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(cabinetStatus, "Отправляем OTP-код...", null);
|
|
||||||
const sendResponse = await fetch("/api/public/otp/send", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
purpose: "VIEW_REQUEST",
|
purpose: "VIEW_REQUEST",
|
||||||
track_number: trackNumber
|
client_phone: phone,
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
const sendData = await parseJsonSafe(sendResponse);
|
const data = await parseJsonSafe(response);
|
||||||
if (!sendResponse.ok) {
|
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить OTP"));
|
||||||
throw new Error(apiErrorDetail(sendData, "Не удалось отправить 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):");
|
try {
|
||||||
if (!code) {
|
setStatus(accessStatus, "Проверяем OTP...", null);
|
||||||
throw new Error("Код OTP не введен");
|
const response = await fetch("/api/public/otp/verify", {
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(cabinetStatus, "Проверяем OTP...", null);
|
|
||||||
const verifyResponse = await fetch("/api/public/otp/verify", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
purpose: "VIEW_REQUEST",
|
purpose: "VIEW_REQUEST",
|
||||||
track_number: trackNumber,
|
client_phone: phone,
|
||||||
code: String(code).trim()
|
code,
|
||||||
})
|
}),
|
||||||
});
|
|
||||||
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 })
|
|
||||||
});
|
});
|
||||||
const data = await parseJsonSafe(response);
|
const data = await parseJsonSafe(response);
|
||||||
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить сообщение"));
|
if (!response.ok) throw new Error(apiErrorDetail(data, "OTP не подтвержден"));
|
||||||
cabinetChatBody.value = "";
|
setStatus(accessStatus, "Доступ подтвержден. Переходим...", "ok");
|
||||||
await refreshCabinetData();
|
window.location.href = "/client.html";
|
||||||
setStatus(cabinetStatus, "Сообщение отправлено.", "ok");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(cabinetStatus, error?.message || "Ошибка отправки сообщения", "error");
|
setStatus(accessStatus, error?.message || "Ошибка проверки OTP", "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cabinetFileUpload.addEventListener("click", async () => {
|
requestForm.addEventListener("submit", async (event) => {
|
||||||
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) => {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setStatus(status, "Отправляем заявку...", null);
|
setStatus(requestStatus, "Отправляем заявку...", null);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
client_name: document.getElementById("name").value.trim(),
|
client_name: String(document.getElementById("name").value || "").trim(),
|
||||||
client_phone: document.getElementById("phone").value.trim(),
|
client_phone: String(document.getElementById("phone").value || "").trim(),
|
||||||
topic_code: "consulting",
|
topic_code: String(document.getElementById("topic").value || "").trim(),
|
||||||
description: document.getElementById("description").value.trim(),
|
description: String(document.getElementById("description").value || "").trim(),
|
||||||
extra_fields: {
|
extra_fields: {},
|
||||||
referral_name: document.getElementById("referral").value.trim()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!payload.client_name || !payload.client_phone || !payload.topic_code) {
|
||||||
|
setStatus(requestStatus, "Заполните имя, телефон и тему обращения.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus(status, "Отправляем OTP-код...", null);
|
setStatus(requestStatus, "Отправляем OTP-код...", null);
|
||||||
const otpSend = await fetch("/api/public/otp/send", {
|
const otpSend = await fetch("/api/public/otp/send", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
purpose: "CREATE_REQUEST",
|
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):");
|
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", {
|
const otpVerify = await fetch("/api/public/otp/verify", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
purpose: "CREATE_REQUEST",
|
purpose: "CREATE_REQUEST",
|
||||||
client_phone: payload.client_phone,
|
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", {
|
const response = await fetch("/api/public/requests", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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");
|
setStatus(requestStatus, "Заявка принята. Номер: " + data.track_number, "ok");
|
||||||
const data = await response.json();
|
requestForm.reset();
|
||||||
setStatus(status, "Заявка принята. Номер: " + data.track_number, "ok");
|
setTimeout(() => closeModal(requestModal), 1200);
|
||||||
cabinetTrackInput.value = data.track_number;
|
|
||||||
form.reset();
|
|
||||||
setTimeout(closeModal, 1200);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(status, "Не удалось отправить заявку. Повторите попытку позже.", "error");
|
setStatus(requestStatus, error?.message || "Не удалось отправить заявку. Повторите попытку позже.", "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loadTopics();
|
||||||
loadQuotes();
|
loadQuotes();
|
||||||
setCabinetEnabled(false);
|
|
||||||
clearList(cabinetMessages, "Сообщений пока нет.");
|
|
||||||
clearList(cabinetFiles, "Файлы пока не загружены.");
|
|
||||||
clearList(cabinetInvoices, "Счета пока не выставлены.");
|
|
||||||
clearList(cabinetTimeline, "История пока пуста.");
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -46,6 +46,18 @@
|
||||||
| P25 | сделано | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) |
|
| P25 | сделано | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) |
|
||||||
| P26 | сделано | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов |
|
| P26 | сделано | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов |
|
||||||
| P27 | сделано | Итоговое тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers и выполнить финальный регрессионный прогон | Набор автотестов фиксирует регрессии критичных сценариев и подтверждает готовность перед приемкой |
|
| 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` (полный контур назначения).
|
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
|
||||||
|
|
@ -53,6 +65,33 @@
|
||||||
3. `P14 -> P15 -> P16` (процесс работы по заявке).
|
3. `P14 -> P15 -> P16` (процесс работы по заявке).
|
||||||
4. `P17 -> P18 -> P24 -> P25 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика).
|
4. `P17 -> P18 -> P24 -> P25 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика).
|
||||||
5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце).
|
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`.
|
1. Не менять бизнес-правила без обновления `context/*.md`.
|
||||||
|
|
@ -62,3 +101,4 @@
|
||||||
5. Для операций назначения использовать транзакционную защиту от гонок.
|
5. Для операций назначения использовать транзакционную защиту от гонок.
|
||||||
6. Для статусов и SLA использовать только серверную валидацию (не доверять фронту).
|
6. Для статусов и SLA использовать только серверную валидацию (не доверять фронту).
|
||||||
7. Перед переводом пункта в `сделано` выполнять проверки из `context/11_test_runbook.md`.
|
7. Перед переводом пункта в `сделано` выполнять проверки из `context/11_test_runbook.md`.
|
||||||
|
8. UI e2e запускать через фиксированный compose-сервис `e2e` (образ `law-e2e-playwright:1.58.2`) для стабильного повторяемого прогона без повторной загрузки браузеров.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Runbook Проверок (Тесты и Валидация по Плану)
|
# 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 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"
|
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
|
```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 | Что проверяем | Где тесты | Как запускать |
|
| 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 не отдает поля ставок/процентов |
|
| 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->`Оплачено` (в т.ч. множественные оплаты в одной заявке) |
|
| 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` |
|
| 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 / LAWYER / ADMIN)
|
||||||
### PUBLIC (клиент)
|
### PUBLIC (клиент)
|
||||||
|
|
@ -68,7 +89,7 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
|
||||||
- Публичные счета и PDF в кабинете: `tests/test_invoices.py`.
|
- Публичные счета и PDF в кабинете: `tests/test_invoices.py`.
|
||||||
|
|
||||||
### LAWYER (юрист)
|
### 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_dashboard_finance.py`.
|
||||||
- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`.
|
- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`.
|
||||||
- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `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 (администратор)
|
### ADMIN (администратор)
|
||||||
- UI e2e: `e2e/tests/admin_role_flow.spec.js` (вход, справочники, создание пользователя/темы, создание и оплата счета).
|
- 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`.
|
- CRUD пользователей/юристов (пароли, роли, профильная тема, аватар): `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py`.
|
||||||
- Темы и флоу статусов (включая ветвление), SLA-переходы: `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`.
|
- Темы и флоу статусов (включая ветвление), SLA-переходы: `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`.
|
||||||
- Шаблоны обязательных/дозапрашиваемых данных: `tests/test_admin_universal_crud.py`, `tests/test_public_requests.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.
|
5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose.
|
||||||
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
|
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
|
||||||
|
|
||||||
## Последний регрессионный прогон
|
## Последние подтвержденные прогоны
|
||||||
- `python -m unittest discover -s tests -p 'test_*.py' -v` — `94 tests OK`.
|
- `docker compose run --rm backend python -m unittest -v tests.test_admin_auth` — `3 passed`.
|
||||||
- `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 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]
|
depends_on: [backend]
|
||||||
ports: ["8081:80"]
|
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:
|
backend:
|
||||||
build: .
|
build: .
|
||||||
container_name: law-backend
|
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;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
absolute_redirect off;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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.admin_user_topic import AdminUserTopic
|
||||||
from app.models.attachment import Attachment
|
from app.models.attachment import Attachment
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.client import Client
|
||||||
from app.models.form_field import FormField
|
from app.models.form_field import FormField
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
|
from app.models.table_availability import TableAvailability
|
||||||
from app.models.quote import Quote
|
from app.models.quote import Quote
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
|
|
@ -49,6 +51,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
AdminUser.__table__.create(bind=cls.engine)
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
Client.__table__.create(bind=cls.engine)
|
||||||
Quote.__table__.create(bind=cls.engine)
|
Quote.__table__.create(bind=cls.engine)
|
||||||
FormField.__table__.create(bind=cls.engine)
|
FormField.__table__.create(bind=cls.engine)
|
||||||
Request.__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)
|
TopicStatusTransition.__table__.create(bind=cls.engine)
|
||||||
AdminUserTopic.__table__.create(bind=cls.engine)
|
AdminUserTopic.__table__.create(bind=cls.engine)
|
||||||
Notification.__table__.create(bind=cls.engine)
|
Notification.__table__.create(bind=cls.engine)
|
||||||
|
TableAvailability.__table__.create(bind=cls.engine)
|
||||||
AuditLog.__table__.create(bind=cls.engine)
|
AuditLog.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def tearDownClass(cls):
|
def tearDownClass(cls):
|
||||||
AuditLog.__table__.drop(bind=cls.engine)
|
AuditLog.__table__.drop(bind=cls.engine)
|
||||||
Notification.__table__.drop(bind=cls.engine)
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
|
TableAvailability.__table__.drop(bind=cls.engine)
|
||||||
AdminUserTopic.__table__.drop(bind=cls.engine)
|
AdminUserTopic.__table__.drop(bind=cls.engine)
|
||||||
RequestDataRequirement.__table__.drop(bind=cls.engine)
|
RequestDataRequirement.__table__.drop(bind=cls.engine)
|
||||||
TopicDataTemplate.__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)
|
Request.__table__.drop(bind=cls.engine)
|
||||||
FormField.__table__.drop(bind=cls.engine)
|
FormField.__table__.drop(bind=cls.engine)
|
||||||
Quote.__table__.drop(bind=cls.engine)
|
Quote.__table__.drop(bind=cls.engine)
|
||||||
|
Client.__table__.drop(bind=cls.engine)
|
||||||
AdminUser.__table__.drop(bind=cls.engine)
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
cls.engine.dispose()
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
|
@ -92,6 +98,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
db.execute(delete(Attachment))
|
db.execute(delete(Attachment))
|
||||||
db.execute(delete(Message))
|
db.execute(delete(Message))
|
||||||
db.execute(delete(Request))
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(Client))
|
||||||
db.execute(delete(Status))
|
db.execute(delete(Status))
|
||||||
db.execute(delete(FormField))
|
db.execute(delete(FormField))
|
||||||
db.execute(delete(Topic))
|
db.execute(delete(Topic))
|
||||||
|
|
@ -101,6 +108,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
db.execute(delete(TopicStatusTransition))
|
db.execute(delete(TopicStatusTransition))
|
||||||
db.execute(delete(AdminUserTopic))
|
db.execute(delete(AdminUserTopic))
|
||||||
db.execute(delete(Notification))
|
db.execute(delete(Notification))
|
||||||
|
db.execute(delete(TableAvailability))
|
||||||
db.execute(delete(Quote))
|
db.execute(delete(Quote))
|
||||||
db.execute(delete(AdminUser))
|
db.execute(delete(AdminUser))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -177,6 +185,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
by_table = {row["table"]: row for row in tables}
|
by_table = {row["table"]: row for row in tables}
|
||||||
self.assertIn("requests", by_table)
|
self.assertIn("requests", by_table)
|
||||||
self.assertIn("invoices", by_table)
|
self.assertIn("invoices", by_table)
|
||||||
|
self.assertIn("clients", by_table)
|
||||||
self.assertIn("quotes", by_table)
|
self.assertIn("quotes", by_table)
|
||||||
self.assertIn("statuses", by_table)
|
self.assertIn("statuses", by_table)
|
||||||
|
|
||||||
|
|
@ -191,7 +200,12 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
self.assertEqual(quotes_columns["sort_order"]["label"], "Порядок")
|
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 [])))
|
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():
|
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)
|
self.assertEqual(table_meta.get("section"), expected_section)
|
||||||
|
|
||||||
admin_users_cols = {col["name"] for col in (by_table["admin_users"].get("columns") or [])}
|
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)
|
forbidden = self.client.get("/api/admin/crud/meta/tables", headers=lawyer_headers)
|
||||||
self.assertEqual(forbidden.status_code, 403)
|
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):
|
def test_lawyer_permissions_and_request_crud(self):
|
||||||
lawyer_headers = self._auth_headers("LAWYER")
|
lawyer_headers = self._auth_headers("LAWYER")
|
||||||
|
|
||||||
|
|
@ -347,6 +410,139 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
self.assertIsNotNone(refreshed)
|
self.assertIsNotNone(refreshed)
|
||||||
self.assertEqual(refreshed.status_code, "CLOSED")
|
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):
|
def test_topic_status_flow_supports_branching_transitions(self):
|
||||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
@ -394,6 +590,95 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(second_branch.status_code, 200)
|
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):
|
def test_request_read_markers_status_update_and_lawyer_open_reset(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
lawyer = AdminUser(
|
lawyer = AdminUser(
|
||||||
|
|
@ -688,6 +973,100 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
self.assertEqual(history[0].from_status, "NEW")
|
self.assertEqual(history[0].from_status, "NEW")
|
||||||
self.assertEqual(history[0].to_status, "IN_PROGRESS")
|
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):
|
def test_lawyer_can_claim_unassigned_request_and_takeover_is_forbidden(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
lawyer1 = AdminUser(
|
lawyer1 = AdminUser(
|
||||||
|
|
@ -936,6 +1315,64 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertIn("Неизвестные поля", response.json().get("detail", ""))
|
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):
|
def test_topic_code_is_autogenerated_when_missing(self):
|
||||||
headers = self._auth_headers("ADMIN")
|
headers = self._auth_headers("ADMIN")
|
||||||
first = self.client.post(
|
first = self.client.post(
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ class MigrationTests(unittest.TestCase):
|
||||||
def test_upgrade_head_creates_expected_tables(self):
|
def test_upgrade_head_creates_expected_tables(self):
|
||||||
expected = {
|
expected = {
|
||||||
"admin_users",
|
"admin_users",
|
||||||
|
"clients",
|
||||||
|
"table_availability",
|
||||||
"topics",
|
"topics",
|
||||||
"statuses",
|
"statuses",
|
||||||
"form_fields",
|
"form_fields",
|
||||||
|
|
@ -106,11 +108,13 @@ class MigrationTests(unittest.TestCase):
|
||||||
def test_alembic_version_is_set(self):
|
def test_alembic_version_is_set(self):
|
||||||
with self.engine.connect() as conn:
|
with self.engine.connect() as conn:
|
||||||
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
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):
|
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||||
tables = {
|
tables = {
|
||||||
"admin_users",
|
"admin_users",
|
||||||
|
"clients",
|
||||||
|
"table_availability",
|
||||||
"topics",
|
"topics",
|
||||||
"statuses",
|
"statuses",
|
||||||
"form_fields",
|
"form_fields",
|
||||||
|
|
@ -171,6 +175,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
|
|
||||||
def test_requests_contains_financial_columns(self):
|
def test_requests_contains_financial_columns(self):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("requests")}
|
columns = {column["name"] for column in self.inspector.get_columns("requests")}
|
||||||
|
self.assertIn("client_id", columns)
|
||||||
self.assertIn("effective_rate", columns)
|
self.assertIn("effective_rate", columns)
|
||||||
self.assertIn("invoice_amount", columns)
|
self.assertIn("invoice_amount", columns)
|
||||||
self.assertIn("paid_at", columns)
|
self.assertIn("paid_at", columns)
|
||||||
|
|
@ -178,6 +183,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
|
|
||||||
def test_invoices_contains_core_columns(self):
|
def test_invoices_contains_core_columns(self):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("invoices")}
|
columns = {column["name"] for column in self.inspector.get_columns("invoices")}
|
||||||
|
self.assertIn("client_id", columns)
|
||||||
self.assertIn("request_id", columns)
|
self.assertIn("request_id", columns)
|
||||||
self.assertIn("invoice_number", columns)
|
self.assertIn("invoice_number", columns)
|
||||||
self.assertIn("status", columns)
|
self.assertIn("status", columns)
|
||||||
|
|
@ -194,3 +200,11 @@ class MigrationTests(unittest.TestCase):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("statuses")}
|
columns = {column["name"] for column in self.inspector.get_columns("statuses")}
|
||||||
self.assertIn("kind", columns)
|
self.assertIn("kind", columns)
|
||||||
self.assertIn("invoice_template", 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.assertTrue(req.lawyer_has_unread_updates)
|
||||||
self.assertEqual(req.lawyer_unread_event_type, "MESSAGE")
|
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):
|
def test_public_cabinet_respects_track_access(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
req = Request(
|
req = Request(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from app.main import app
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import create_jwt, decode_jwt
|
from app.core.security import create_jwt, decode_jwt
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
from app.models.client import Client
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
from app.models.otp_session import OtpSession
|
from app.models.otp_session import OtpSession
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
|
|
@ -36,6 +37,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
poolclass=StaticPool,
|
poolclass=StaticPool,
|
||||||
)
|
)
|
||||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
Client.__table__.create(bind=cls.engine)
|
||||||
Request.__table__.create(bind=cls.engine)
|
Request.__table__.create(bind=cls.engine)
|
||||||
Notification.__table__.create(bind=cls.engine)
|
Notification.__table__.create(bind=cls.engine)
|
||||||
OtpSession.__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)
|
OtpSession.__table__.drop(bind=cls.engine)
|
||||||
TopicRequiredField.__table__.drop(bind=cls.engine)
|
TopicRequiredField.__table__.drop(bind=cls.engine)
|
||||||
Request.__table__.drop(bind=cls.engine)
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
Client.__table__.drop(bind=cls.engine)
|
||||||
cls.engine.dispose()
|
cls.engine.dispose()
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
@ -55,6 +58,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
db.execute(delete(OtpSession))
|
db.execute(delete(OtpSession))
|
||||||
db.execute(delete(TopicRequiredField))
|
db.execute(delete(TopicRequiredField))
|
||||||
db.execute(delete(Request))
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(Client))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
def override_get_db():
|
def override_get_db():
|
||||||
|
|
@ -114,12 +118,16 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
self.assertIsNotNone(created)
|
self.assertIsNotNone(created)
|
||||||
self.assertEqual(created.client_name, payload["client_name"])
|
self.assertEqual(created.client_name, payload["client_name"])
|
||||||
self.assertEqual(created.client_phone, payload["client_phone"])
|
self.assertEqual(created.client_phone, payload["client_phone"])
|
||||||
|
self.assertIsNotNone(created.client_id)
|
||||||
self.assertEqual(created.topic_code, payload["topic_code"])
|
self.assertEqual(created.topic_code, payload["topic_code"])
|
||||||
self.assertEqual(created.description, payload["description"])
|
self.assertEqual(created.description, payload["description"])
|
||||||
self.assertEqual(created.extra_fields, payload["extra_fields"])
|
self.assertEqual(created.extra_fields, payload["extra_fields"])
|
||||||
self.assertEqual(created.status_code, "NEW")
|
self.assertEqual(created.status_code, "NEW")
|
||||||
self.assertEqual(created.track_number, body["track_number"])
|
self.assertEqual(created.track_number, body["track_number"])
|
||||||
self.assertEqual(created.responsible, "Клиент")
|
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.
|
# After creation, cookie is switched to VIEW_REQUEST for this track.
|
||||||
read = self.client.get(f"/api/public/requests/{body['track_number']}")
|
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")
|
denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
|
||||||
self.assertEqual(denied_other_track.status_code, 403)
|
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):
|
def test_open_request_marks_client_updates_as_read(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
row = Request(
|
row = Request(
|
||||||
|
|
@ -274,3 +349,38 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
self.assertIn(f"{settings.PUBLIC_COOKIE_NAME}=", cookie_header)
|
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(f"Max-Age={settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600}", cookie_header)
|
||||||
self.assertIn("httponly", cookie_header.lower())
|
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 import AdminUser
|
||||||
from app.models.admin_user_topic import AdminUserTopic
|
from app.models.admin_user_topic import AdminUserTopic
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.client import Client
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
|
|
@ -40,6 +41,7 @@ class RequestRatesTests(unittest.TestCase):
|
||||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
AdminUser.__table__.create(bind=cls.engine)
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
AdminUserTopic.__table__.create(bind=cls.engine)
|
AdminUserTopic.__table__.create(bind=cls.engine)
|
||||||
|
Client.__table__.create(bind=cls.engine)
|
||||||
Request.__table__.create(bind=cls.engine)
|
Request.__table__.create(bind=cls.engine)
|
||||||
Status.__table__.create(bind=cls.engine)
|
Status.__table__.create(bind=cls.engine)
|
||||||
TopicRequiredField.__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)
|
TopicRequiredField.__table__.drop(bind=cls.engine)
|
||||||
Status.__table__.drop(bind=cls.engine)
|
Status.__table__.drop(bind=cls.engine)
|
||||||
Request.__table__.drop(bind=cls.engine)
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
Client.__table__.drop(bind=cls.engine)
|
||||||
AdminUserTopic.__table__.drop(bind=cls.engine)
|
AdminUserTopic.__table__.drop(bind=cls.engine)
|
||||||
AdminUser.__table__.drop(bind=cls.engine)
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
cls.engine.dispose()
|
cls.engine.dispose()
|
||||||
|
|
@ -68,6 +71,7 @@ class RequestRatesTests(unittest.TestCase):
|
||||||
db.execute(delete(TopicRequiredField))
|
db.execute(delete(TopicRequiredField))
|
||||||
db.execute(delete(Status))
|
db.execute(delete(Status))
|
||||||
db.execute(delete(Request))
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(Client))
|
||||||
db.execute(delete(AdminUserTopic))
|
db.execute(delete(AdminUserTopic))
|
||||||
db.execute(delete(AdminUser))
|
db.execute(delete(AdminUser))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,44 @@ class UploadsS3Tests(unittest.TestCase):
|
||||||
self.assertEqual(len(rows), 1)
|
self.assertEqual(len(rows), 1)
|
||||||
self.assertEqual(rows[0].s3_key, key)
|
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):
|
def test_admin_request_attachment_upload_sets_client_unread_marker(self):
|
||||||
fake_s3 = _FakeS3Storage()
|
fake_s3 = _FakeS3Storage()
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue