mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
Task P054-P057
This commit is contained in:
parent
5ff2a32087
commit
ff169cb42d
69 changed files with 8435 additions and 5834 deletions
20
README.md
20
README.md
|
|
@ -21,3 +21,23 @@ docker compose exec backend alembic upgrade head
|
|||
make seed-quotes
|
||||
```
|
||||
Loads 50 justice-themed quotes into `quotes` with idempotent upsert by `(author, text)`.
|
||||
|
||||
## OTP SMS provider (SMS Aero)
|
||||
OTP sending is implemented through a dedicated SMS service layer (`app/services/sms_service.py`).
|
||||
|
||||
Configure provider in `.env`:
|
||||
```bash
|
||||
SMS_PROVIDER=smsaero
|
||||
SMSAERO_EMAIL=your_email@example.com
|
||||
SMSAERO_API_KEY=your_api_key
|
||||
OTP_SMS_TEMPLATE=Your verification code: {code}
|
||||
```
|
||||
|
||||
For local/dev mock mode:
|
||||
```bash
|
||||
SMS_PROVIDER=dummy
|
||||
```
|
||||
In this mode OTP code is printed to backend logs.
|
||||
|
||||
Admin health-check endpoint (no SMS send):
|
||||
`GET /api/admin/system/sms-provider-health`
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from app.models.admin_user_topic import AdminUserTopic
|
|||
from app.models.notification import Notification
|
||||
from app.models.invoice import Invoice
|
||||
from app.models.security_audit_log import SecurityAuditLog
|
||||
from app.models.request_service_request import RequestServiceRequest
|
||||
|
||||
config = context.config
|
||||
fileConfig(config.config_file_name)
|
||||
|
|
|
|||
78
alembic/versions/0025_add_request_service_requests.py
Normal file
78
alembic/versions/0025_add_request_service_requests.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""add request service requests table
|
||||
|
||||
Revision ID: 0025_service_requests
|
||||
Revises: 0024_featured_staff_carousel
|
||||
Create Date: 2026-02-27 14:45:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0025_service_requests"
|
||||
down_revision = "0024_featured_staff_carousel"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"request_service_requests",
|
||||
sa.Column("request_id", sa.String(length=60), nullable=False),
|
||||
sa.Column("client_id", sa.String(length=60), nullable=True),
|
||||
sa.Column("assigned_lawyer_id", sa.String(length=60), nullable=True),
|
||||
sa.Column("resolved_by_admin_id", sa.String(length=60), nullable=True),
|
||||
sa.Column("type", sa.String(length=40), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False, server_default="NEW"),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("created_by_client", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("admin_unread", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("lawyer_unread", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("admin_read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("lawyer_read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
|
||||
)
|
||||
op.create_index(op.f("ix_request_service_requests_request_id"), "request_service_requests", ["request_id"], unique=False)
|
||||
op.create_index(op.f("ix_request_service_requests_client_id"), "request_service_requests", ["client_id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_request_service_requests_assigned_lawyer_id"),
|
||||
"request_service_requests",
|
||||
["assigned_lawyer_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_request_service_requests_resolved_by_admin_id"),
|
||||
"request_service_requests",
|
||||
["resolved_by_admin_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(op.f("ix_request_service_requests_type"), "request_service_requests", ["type"], unique=False)
|
||||
op.create_index(op.f("ix_request_service_requests_status"), "request_service_requests", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_request_service_requests_admin_unread"), "request_service_requests", ["admin_unread"], unique=False)
|
||||
op.create_index(op.f("ix_request_service_requests_lawyer_unread"), "request_service_requests", ["lawyer_unread"], unique=False)
|
||||
|
||||
op.alter_column("request_service_requests", "status", server_default=None)
|
||||
op.alter_column("request_service_requests", "created_by_client", server_default=None)
|
||||
op.alter_column("request_service_requests", "admin_unread", server_default=None)
|
||||
op.alter_column("request_service_requests", "lawyer_unread", server_default=None)
|
||||
op.alter_column("request_service_requests", "responsible", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_request_service_requests_lawyer_unread"), table_name="request_service_requests")
|
||||
op.drop_index(op.f("ix_request_service_requests_admin_unread"), table_name="request_service_requests")
|
||||
op.drop_index(op.f("ix_request_service_requests_status"), table_name="request_service_requests")
|
||||
op.drop_index(op.f("ix_request_service_requests_type"), table_name="request_service_requests")
|
||||
op.drop_index(op.f("ix_request_service_requests_resolved_by_admin_id"), table_name="request_service_requests")
|
||||
op.drop_index(op.f("ix_request_service_requests_assigned_lawyer_id"), table_name="request_service_requests")
|
||||
op.drop_index(op.f("ix_request_service_requests_client_id"), table_name="request_service_requests")
|
||||
op.drop_index(op.f("ix_request_service_requests_request_id"), table_name="request_service_requests")
|
||||
op.drop_table("request_service_requests")
|
||||
57
alembic/versions/0026_request_service_requests_string_ids.py
Normal file
57
alembic/versions/0026_request_service_requests_string_ids.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""normalize request_service_requests link column types to varchar
|
||||
|
||||
Revision ID: 0026_srv_req_str_ids
|
||||
Revises: 0025_service_requests
|
||||
Create Date: 2026-02-27 15:40:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0026_srv_req_str_ids"
|
||||
down_revision = "0025_service_requests"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _postgres_alter_to_varchar(column_name: str) -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
ALTER TABLE request_service_requests
|
||||
ALTER COLUMN {column_name} TYPE VARCHAR(60)
|
||||
USING {column_name}::text
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _postgres_alter_to_uuid(column_name: str) -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
ALTER TABLE request_service_requests
|
||||
ALTER COLUMN {column_name} TYPE UUID
|
||||
USING NULLIF({column_name}, '')::uuid
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
for name in ("request_id", "client_id", "assigned_lawyer_id", "resolved_by_admin_id"):
|
||||
_postgres_alter_to_varchar(name)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
for name in ("request_id", "client_id", "assigned_lawyer_id", "resolved_by_admin_id"):
|
||||
_postgres_alter_to_uuid(name)
|
||||
|
||||
|
|
@ -183,7 +183,7 @@ def _serialize_data_request_items(db: Session, rows: list[RequestDataRequirement
|
|||
def list_request_messages(
|
||||
request_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||
):
|
||||
req = _request_for_id_or_404(db, request_id)
|
||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
|
|
@ -196,7 +196,7 @@ def create_request_message(
|
|||
request_id: str,
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||
):
|
||||
req = _request_for_id_or_404(db, request_id)
|
||||
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
|
|
@ -229,7 +229,7 @@ def list_data_request_templates(
|
|||
request_id: str,
|
||||
document: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||
):
|
||||
req = _request_for_id_or_404(db, request_id)
|
||||
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
|
|
@ -273,7 +273,7 @@ def get_data_request_batch(
|
|||
request_id: str,
|
||||
message_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||
):
|
||||
req = _request_for_id_or_404(db, request_id)
|
||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
|
|
@ -306,7 +306,7 @@ def get_data_request_template(
|
|||
request_id: str,
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||
):
|
||||
req = _request_for_id_or_404(db, request_id)
|
||||
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
|
|
@ -333,7 +333,7 @@ def save_data_request_template(
|
|||
request_id: str,
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||
):
|
||||
req = _request_for_id_or_404(db, request_id)
|
||||
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
|
|
@ -497,7 +497,7 @@ def upsert_data_request_batch(
|
|||
request_id: str,
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||
):
|
||||
req = _request_for_id_or_404(db, request_id)
|
||||
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
3
app/api/admin/crud_modules/__init__.py
Normal file
3
app/api/admin/crud_modules/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
164
app/api/admin/crud_modules/access.py
Normal file
164
app/api/admin/crud_modules/access.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import app.models as models_pkg
|
||||
from app.db.session import Base
|
||||
from app.models.request import Request
|
||||
|
||||
CRUD_ACTIONS = {"query", "read", "create", "update", "delete"}
|
||||
SYSTEM_FIELDS = {
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"responsible",
|
||||
"client_has_unread_updates",
|
||||
"client_unread_event_type",
|
||||
"lawyer_has_unread_updates",
|
||||
"lawyer_unread_event_type",
|
||||
}
|
||||
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
|
||||
REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"}
|
||||
INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"}
|
||||
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER", "CURATOR"}
|
||||
ALLOWED_REQUEST_DATA_VALUE_TYPES = {"string", "text", "date", "number", "file"}
|
||||
|
||||
# Per-table RBAC: table -> role -> actions.
|
||||
# If a table is missing here, fallback rules are used.
|
||||
TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
|
||||
"requests": {
|
||||
"ADMIN": set(CRUD_ACTIONS),
|
||||
"LAWYER": set(CRUD_ACTIONS),
|
||||
"CURATOR": {"query", "read"},
|
||||
},
|
||||
"messages": {
|
||||
"ADMIN": set(CRUD_ACTIONS),
|
||||
"LAWYER": {"query", "read", "create"},
|
||||
},
|
||||
"attachments": {
|
||||
"ADMIN": set(CRUD_ACTIONS),
|
||||
"LAWYER": {"query", "read"},
|
||||
},
|
||||
"quotes": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"topics": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"statuses": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"status_groups": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"form_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"clients": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"table_availability": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"audit_log": {"ADMIN": {"query", "read"}},
|
||||
"security_audit_log": {"ADMIN": {"query", "read"}},
|
||||
"otp_sessions": {"ADMIN": {"query", "read"}},
|
||||
"admin_users": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"landing_featured_staff": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"topic_required_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"topic_data_templates": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"request_data_templates": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"request_data_template_items": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"request_data_requirements": {"ADMIN": set(CRUD_ACTIONS)},
|
||||
"request_service_requests": {
|
||||
"ADMIN": set(CRUD_ACTIONS),
|
||||
"LAWYER": {"query", "read"},
|
||||
"CURATOR": {"query", "read", "update"},
|
||||
},
|
||||
"notifications": {"ADMIN": {"query", "read", "update"}},
|
||||
}
|
||||
|
||||
DEFAULT_ROLE_ACTIONS: dict[str, set[str]] = {
|
||||
"ADMIN": set(CRUD_ACTIONS),
|
||||
"CURATOR": {"query", "read"},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_table_name(table_name: str) -> str:
|
||||
raw = (table_name or "").strip().replace("-", "_")
|
||||
if not raw:
|
||||
return ""
|
||||
chars: list[str] = []
|
||||
for index, ch in enumerate(raw):
|
||||
if ch.isupper() and index > 0 and raw[index - 1].isalnum() and raw[index - 1] != "_":
|
||||
chars.append("_")
|
||||
chars.append(ch.lower())
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _table_model_map() -> dict[str, type]:
|
||||
for module in pkgutil.iter_modules(models_pkg.__path__):
|
||||
if module.name.startswith("_"):
|
||||
continue
|
||||
importlib.import_module(f"{models_pkg.__name__}.{module.name}")
|
||||
return {
|
||||
mapper.class_.__tablename__: mapper.class_
|
||||
for mapper in Base.registry.mappers
|
||||
if getattr(mapper.class_, "__tablename__", None)
|
||||
}
|
||||
|
||||
|
||||
def _resolve_table_model(table_name: str) -> tuple[str, type]:
|
||||
normalized = _normalize_table_name(table_name)
|
||||
model = _table_model_map().get(normalized)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=404, detail="Таблица не найдена")
|
||||
return normalized, model
|
||||
|
||||
|
||||
def _allowed_actions(role: str, table_name: str) -> set[str]:
|
||||
per_table = TABLE_ROLE_ACTIONS.get(table_name)
|
||||
if per_table is not None:
|
||||
return set(per_table.get(role, set()))
|
||||
return set(DEFAULT_ROLE_ACTIONS.get(role, set()))
|
||||
|
||||
|
||||
def _require_table_action(admin: dict, table_name: str, action: str) -> None:
|
||||
role = str(admin.get("role") or "").upper()
|
||||
allowed = _allowed_actions(role, table_name)
|
||||
if action not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||
|
||||
|
||||
def _is_lawyer(admin: dict) -> bool:
|
||||
return str(admin.get("role") or "").upper() == "LAWYER"
|
||||
|
||||
|
||||
def _lawyer_actor_id_or_401(admin: dict) -> str:
|
||||
actor_id = str(admin.get("sub") or "").strip()
|
||||
if not actor_id:
|
||||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||
return actor_id
|
||||
|
||||
|
||||
def _ensure_lawyer_can_view_request_or_403(admin: dict, req: Request) -> None:
|
||||
if not _is_lawyer(admin):
|
||||
return
|
||||
actor_id = _lawyer_actor_id_or_401(admin)
|
||||
assigned = str(req.assigned_lawyer_id or "").strip()
|
||||
if assigned and assigned != actor_id:
|
||||
raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки")
|
||||
|
||||
|
||||
def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
|
||||
if not _is_lawyer(admin):
|
||||
return
|
||||
actor_id = _lawyer_actor_id_or_401(admin)
|
||||
assigned = str(req.assigned_lawyer_id or "").strip()
|
||||
if not assigned or assigned != actor_id:
|
||||
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
|
||||
55
app/api/admin/crud_modules/audit.py
Normal file
55
app/api/admin/crud_modules/audit.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
from .meta import _hidden_response_fields
|
||||
|
||||
def _resolve_responsible(admin: dict | None) -> str:
|
||||
if not admin:
|
||||
return "Администратор системы"
|
||||
email = str(admin.get("email") or "").strip()
|
||||
return email or "Администратор системы"
|
||||
|
||||
|
||||
def _strip_hidden_fields(table_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
hidden = _hidden_response_fields(table_name)
|
||||
if not hidden:
|
||||
return payload
|
||||
return {k: v for k, v in payload.items() if k not in hidden}
|
||||
|
||||
|
||||
def _actor_uuid(admin: dict) -> uuid.UUID | None:
|
||||
sub = admin.get("sub")
|
||||
if not sub:
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(sub))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _append_audit(db: Session, admin: dict, table_name: str, entity_id: str, action: str, diff: dict[str, Any]) -> None:
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_admin_id=_actor_uuid(admin),
|
||||
entity=table_name,
|
||||
entity_id=str(entity_id),
|
||||
action=action,
|
||||
diff=diff,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _integrity_error(detail: str = "Нарушение ограничений данных") -> HTTPException:
|
||||
return HTTPException(status_code=400, detail=detail)
|
||||
|
||||
|
||||
def _actor_role(admin: dict) -> str:
|
||||
role = str(admin.get("role") or "").strip().upper()
|
||||
return role or "ADMIN"
|
||||
532
app/api/admin/crud_modules/meta.py
Normal file
532
app/api/admin/crud_modules/meta.py
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.inspection import inspect as sa_inspect
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql.sqltypes import Boolean, Date, DateTime, Float, Integer, JSON, Numeric
|
||||
|
||||
from app.models.table_availability import TableAvailability
|
||||
|
||||
from .access import (
|
||||
REQUEST_CALCULATED_FIELDS,
|
||||
INVOICE_CALCULATED_FIELDS,
|
||||
SYSTEM_FIELDS,
|
||||
_allowed_actions,
|
||||
_normalize_table_name,
|
||||
_resolve_table_model,
|
||||
_table_model_map,
|
||||
)
|
||||
|
||||
def _serialize_value(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: _serialize_value(val) for key, val in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_serialize_value(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [_serialize_value(item) for item in value]
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
return value
|
||||
|
||||
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
mapper = sa_inspect(type(row))
|
||||
return {column.key: _serialize_value(getattr(row, column.key)) for column in mapper.columns}
|
||||
|
||||
|
||||
def _columns_map(model: type) -> dict[str, Any]:
|
||||
mapper = sa_inspect(model)
|
||||
return {column.key: column for column in mapper.columns}
|
||||
|
||||
|
||||
def _column_kind(column: Any) -> str:
|
||||
col_type = column.type
|
||||
if isinstance(col_type, Boolean):
|
||||
return "boolean"
|
||||
if isinstance(col_type, (Integer, Numeric, Float)):
|
||||
return "number"
|
||||
if isinstance(col_type, DateTime):
|
||||
return "datetime"
|
||||
if isinstance(col_type, Date):
|
||||
return "date"
|
||||
if isinstance(col_type, JSON):
|
||||
return "json"
|
||||
try:
|
||||
python_type = col_type.python_type
|
||||
except Exception:
|
||||
python_type = None
|
||||
if python_type is uuid.UUID:
|
||||
return "uuid"
|
||||
return "text"
|
||||
|
||||
|
||||
def _table_label(table_name: str) -> str:
|
||||
normalized = _normalize_table_name(table_name)
|
||||
if not normalized:
|
||||
return "Таблица"
|
||||
|
||||
explicit_labels = {
|
||||
"requests": "Заявки",
|
||||
"invoices": "Счета",
|
||||
"quotes": "Цитаты",
|
||||
"topics": "Темы",
|
||||
"statuses": "Статусы",
|
||||
"status_groups": "Группы статусов",
|
||||
"form_fields": "Поля формы",
|
||||
"clients": "Клиенты",
|
||||
"table_availability": "Доступность таблиц",
|
||||
"topic_required_fields": "Обязательные поля темы",
|
||||
"topic_data_templates": "Дополнительные данные",
|
||||
"request_data_templates": "Шаблоны доп. данных",
|
||||
"request_data_template_items": "Набор данных шаблона",
|
||||
"topic_status_transitions": "Переходы статусов темы",
|
||||
"admin_users": "Пользователи",
|
||||
"admin_user_topics": "Дополнительные темы юристов",
|
||||
"landing_featured_staff": "Карусель сотрудников лендинга",
|
||||
"attachments": "Вложения",
|
||||
"messages": "Сообщения",
|
||||
"audit_log": "Журнал аудита",
|
||||
"security_audit_log": "Журнал безопасности файлов",
|
||||
"status_history": "История статусов",
|
||||
"request_data_requirements": "Требования данных заявки",
|
||||
"request_service_requests": "Запросы",
|
||||
"otp_sessions": "OTP-сессии",
|
||||
"notifications": "Уведомления",
|
||||
}
|
||||
if normalized in explicit_labels:
|
||||
return explicit_labels[normalized]
|
||||
|
||||
return _humanize_identifier_ru(normalized)
|
||||
|
||||
|
||||
def _humanize_identifier_ru(identifier: str) -> str:
|
||||
normalized = _normalize_table_name(identifier)
|
||||
if not normalized:
|
||||
return "Таблица"
|
||||
|
||||
token_labels = {
|
||||
"request": "заявка",
|
||||
"requests": "заявки",
|
||||
"invoice": "счет",
|
||||
"invoices": "счета",
|
||||
"topic": "тема",
|
||||
"topics": "темы",
|
||||
"status": "статус",
|
||||
"statuses": "статусы",
|
||||
"transition": "переход",
|
||||
"transitions": "переходы",
|
||||
"required": "обязательные",
|
||||
"form": "формы",
|
||||
"field": "поле",
|
||||
"fields": "поля",
|
||||
"template": "шаблон",
|
||||
"templates": "шаблоны",
|
||||
"data": "данных",
|
||||
"requirement": "требование",
|
||||
"requirements": "требования",
|
||||
"admin": "админ",
|
||||
"user": "пользователь",
|
||||
"users": "пользователи",
|
||||
"quote": "цитата",
|
||||
"quotes": "цитаты",
|
||||
"message": "сообщение",
|
||||
"messages": "сообщения",
|
||||
"attachment": "вложение",
|
||||
"attachments": "вложения",
|
||||
"notification": "уведомление",
|
||||
"notifications": "уведомления",
|
||||
"audit": "аудита",
|
||||
"security": "безопасности",
|
||||
"log": "журнал",
|
||||
"history": "история",
|
||||
"otp": "OTP",
|
||||
"session": "сессия",
|
||||
"sessions": "сессии",
|
||||
"id": "ID",
|
||||
}
|
||||
words = [token_labels.get(token, token) for token in normalized.split("_") if token]
|
||||
if not words:
|
||||
return "Таблица"
|
||||
phrase = " ".join(words).strip()
|
||||
return phrase[:1].upper() + phrase[1:] if phrase else "Таблица"
|
||||
|
||||
|
||||
def _column_label(table_name: str, column_name: str) -> str:
|
||||
normalized_table = _normalize_table_name(table_name)
|
||||
normalized_column = _normalize_table_name(column_name)
|
||||
if not normalized_column:
|
||||
return "Поле"
|
||||
|
||||
table_overrides = {
|
||||
("invoices", "request_id"): "ID заявки",
|
||||
("invoices", "issued_by_admin_user_id"): "ID сотрудника",
|
||||
("request_data_requirements", "request_id"): "ID заявки",
|
||||
}
|
||||
if (normalized_table, normalized_column) in table_overrides:
|
||||
return table_overrides[(normalized_table, normalized_column)]
|
||||
|
||||
explicit = {
|
||||
"id": "ID",
|
||||
"code": "Код",
|
||||
"key": "Ключ",
|
||||
"name": "Название",
|
||||
"label": "Метка",
|
||||
"caption": "Подпись",
|
||||
"value_type": "Тип значения",
|
||||
"document_name": "Документ",
|
||||
"request_data_template_id": "Шаблон",
|
||||
"request_data_template_item_id": "Элемент шаблона",
|
||||
"text": "Текст",
|
||||
"description": "Описание",
|
||||
"request_message_id": "ID сообщения запроса",
|
||||
"created_by_client": "Создан клиентом",
|
||||
"admin_unread": "Не прочитано администратором",
|
||||
"lawyer_unread": "Не прочитано юристом",
|
||||
"admin_read_at": "Прочитано администратором",
|
||||
"lawyer_read_at": "Прочитано юристом",
|
||||
"resolved_at": "Дата обработки",
|
||||
"field_type": "Тип поля",
|
||||
"value_text": "Данные",
|
||||
"author": "Автор",
|
||||
"source": "Источник",
|
||||
"email": "Email",
|
||||
"role": "Роль",
|
||||
"kind": "Тип",
|
||||
"status_group_id": "Группа",
|
||||
"status": "Статус",
|
||||
"status_code": "Статус",
|
||||
"topic_code": "Тема",
|
||||
"from_status": "Из статуса",
|
||||
"to_status": "В статус",
|
||||
"track_number": "Номер заявки",
|
||||
"invoice_number": "Номер счета",
|
||||
"invoice_template": "Шаблон счета",
|
||||
"amount": "Сумма",
|
||||
"currency": "Валюта",
|
||||
"client_name": "Клиент",
|
||||
"client_id": "Клиент (ID)",
|
||||
"client_phone": "Телефон",
|
||||
"payer_display_name": "Плательщик",
|
||||
"payer_details_encrypted": "Реквизиты (шифр.)",
|
||||
"issued_at": "Дата формирования",
|
||||
"paid_at": "Дата оплаты",
|
||||
"created_at": "Дата создания",
|
||||
"updated_at": "Дата обновления",
|
||||
"responsible": "Ответственный",
|
||||
"sort_order": "Порядок",
|
||||
"pinned": "Закреплен",
|
||||
"is_active": "Активен",
|
||||
"enabled": "Активен",
|
||||
"required": "Обязательное",
|
||||
"nullable": "Может быть пустым",
|
||||
"is_terminal": "Терминальный",
|
||||
"request_id": "ID заявки",
|
||||
"admin_user_id": "ID пользователя",
|
||||
"assigned_lawyer_id": "Назначенный юрист",
|
||||
"issued_by_admin_user_id": "ID сотрудника",
|
||||
"primary_topic_code": "Профильная тема",
|
||||
"default_rate": "Ставка по умолчанию",
|
||||
"effective_rate": "Ставка (фикс.)",
|
||||
"request_cost": "Стоимость заявки",
|
||||
"salary_percent": "Процент зарплаты",
|
||||
"invoice_amount": "Сумма счета",
|
||||
"paid_by_admin_id": "Оплату подтвердил",
|
||||
"resolved_by_admin_id": "Обработал",
|
||||
"extra_fields": "Доп. поля",
|
||||
"total_attachments_bytes": "Размер вложений (байт)",
|
||||
"type": "Тип",
|
||||
"options": "Опции",
|
||||
"field_key": "Поле формы",
|
||||
"sla_hours": "SLA (часы)",
|
||||
"required_data_keys": "Обязательные данные шага",
|
||||
"required_mime_types": "Обязательные файлы шага",
|
||||
"avatar_url": "Аватар",
|
||||
"file_name": "Имя файла",
|
||||
"mime_type": "MIME-тип",
|
||||
"size_bytes": "Размер (байт)",
|
||||
"s3_key": "Ключ S3",
|
||||
"author_type": "Автор",
|
||||
"is_fulfilled": "Выполнено",
|
||||
"requested_by_admin_user_id": "Запросил сотрудник",
|
||||
"fulfilled_at": "Дата выполнения",
|
||||
"title": "Заголовок",
|
||||
"body": "Текст",
|
||||
"event_type": "Тип события",
|
||||
"is_read": "Прочитано",
|
||||
"read_at": "Дата прочтения",
|
||||
"notified_at": "Дата уведомления",
|
||||
"otp_code": "OTP-код",
|
||||
"phone": "Телефон",
|
||||
"verified_at": "Подтверждено",
|
||||
"expires_at": "Истекает",
|
||||
"action": "Действие",
|
||||
"entity": "Сущность",
|
||||
"entity_id": "ID сущности",
|
||||
"actor_admin_id": "ID автора",
|
||||
"actor_role": "Роль субъекта",
|
||||
"actor_subject": "Субъект",
|
||||
"actor_ip": "IP адрес",
|
||||
"allowed": "Разрешено",
|
||||
"reason": "Причина",
|
||||
"diff": "Изменения",
|
||||
"details": "Детали",
|
||||
"table_name": "Таблица",
|
||||
}
|
||||
if normalized_column in explicit:
|
||||
return explicit[normalized_column]
|
||||
|
||||
return _humanize_identifier_ru(normalized_column)
|
||||
|
||||
|
||||
def _pluralize_identifier(base: str) -> list[str]:
|
||||
token = _normalize_table_name(base)
|
||||
if not token:
|
||||
return []
|
||||
candidates = [token]
|
||||
if token.endswith("y"):
|
||||
candidates.append(token[:-1] + "ies")
|
||||
candidates.append(token + "s")
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _reference_override(table_name: str, column_name: str) -> tuple[str, str] | None:
|
||||
normalized_table = _normalize_table_name(table_name)
|
||||
normalized_column = _normalize_table_name(column_name)
|
||||
explicit: dict[tuple[str, str], tuple[str, str]] = {
|
||||
("requests", "assigned_lawyer_id"): ("admin_users", "id"),
|
||||
("requests", "paid_by_admin_id"): ("admin_users", "id"),
|
||||
("requests", "topic_code"): ("topics", "code"),
|
||||
("requests", "status_code"): ("statuses", "code"),
|
||||
("statuses", "status_group_id"): ("status_groups", "id"),
|
||||
("topic_required_fields", "topic_code"): ("topics", "code"),
|
||||
("topic_required_fields", "field_key"): ("form_fields", "key"),
|
||||
("topic_data_templates", "topic_code"): ("topics", "code"),
|
||||
("request_data_templates", "topic_code"): ("topics", "code"),
|
||||
("request_data_templates", "created_by_admin_id"): ("admin_users", "id"),
|
||||
("request_data_template_items", "request_data_template_id"): ("request_data_templates", "id"),
|
||||
("request_data_template_items", "topic_data_template_id"): ("topic_data_templates", "id"),
|
||||
("topic_status_transitions", "topic_code"): ("topics", "code"),
|
||||
("topic_status_transitions", "from_status"): ("statuses", "code"),
|
||||
("topic_status_transitions", "to_status"): ("statuses", "code"),
|
||||
("admin_users", "primary_topic_code"): ("topics", "code"),
|
||||
("admin_user_topics", "admin_user_id"): ("admin_users", "id"),
|
||||
("admin_user_topics", "topic_code"): ("topics", "code"),
|
||||
("landing_featured_staff", "admin_user_id"): ("admin_users", "id"),
|
||||
("request_data_requirements", "request_id"): ("requests", "id"),
|
||||
("request_data_requirements", "topic_template_id"): ("topic_data_templates", "id"),
|
||||
("request_data_requirements", "created_by_admin_id"): ("admin_users", "id"),
|
||||
("request_service_requests", "request_id"): ("requests", "id"),
|
||||
("request_service_requests", "client_id"): ("clients", "id"),
|
||||
("request_service_requests", "assigned_lawyer_id"): ("admin_users", "id"),
|
||||
("request_service_requests", "resolved_by_admin_id"): ("admin_users", "id"),
|
||||
("messages", "request_id"): ("requests", "id"),
|
||||
("attachments", "request_id"): ("requests", "id"),
|
||||
("attachments", "message_id"): ("messages", "id"),
|
||||
("invoices", "request_id"): ("requests", "id"),
|
||||
("invoices", "client_id"): ("clients", "id"),
|
||||
("invoices", "issued_by_admin_user_id"): ("admin_users", "id"),
|
||||
("notifications", "recipient_admin_user_id"): ("admin_users", "id"),
|
||||
("status_history", "request_id"): ("requests", "id"),
|
||||
("status_history", "changed_by_admin_id"): ("admin_users", "id"),
|
||||
("audit_log", "actor_admin_id"): ("admin_users", "id"),
|
||||
}
|
||||
if (normalized_table, normalized_column) in explicit:
|
||||
return explicit[(normalized_table, normalized_column)]
|
||||
return None
|
||||
|
||||
|
||||
def _detect_reference_for_column(table_name: str, column_name: str) -> tuple[str, str] | None:
|
||||
override = _reference_override(table_name, column_name)
|
||||
if override is not None:
|
||||
return override
|
||||
|
||||
normalized = _normalize_table_name(column_name)
|
||||
table_models = _table_model_map()
|
||||
|
||||
if normalized.endswith("_id") and normalized not in {"id"}:
|
||||
base = normalized[:-3]
|
||||
for candidate in _pluralize_identifier(base):
|
||||
if candidate in table_models:
|
||||
return candidate, "id"
|
||||
if base.endswith("_admin_user"):
|
||||
return "admin_users", "id"
|
||||
if base.endswith("_lawyer"):
|
||||
return "admin_users", "id"
|
||||
|
||||
if normalized.endswith("_code"):
|
||||
base = normalized[:-5]
|
||||
for candidate in _pluralize_identifier(base):
|
||||
if candidate in table_models:
|
||||
return candidate, "code"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _reference_label_field(table_name: str, value_field: str) -> str:
|
||||
explicit = {
|
||||
"admin_users": "name",
|
||||
"clients": "full_name",
|
||||
"requests": "track_number",
|
||||
"topics": "name",
|
||||
"statuses": "name",
|
||||
"status_groups": "name",
|
||||
"form_fields": "label",
|
||||
"topic_data_templates": "label",
|
||||
"request_data_templates": "name",
|
||||
"request_data_template_items": "label",
|
||||
"invoices": "invoice_number",
|
||||
"messages": "body",
|
||||
"attachments": "file_name",
|
||||
}
|
||||
if table_name in explicit:
|
||||
return explicit[table_name]
|
||||
|
||||
_, model = _resolve_table_model(table_name)
|
||||
mapper = sa_inspect(model)
|
||||
hidden = _hidden_response_fields(table_name)
|
||||
blocked = {"id", value_field, "created_at", "updated_at", "responsible"}
|
||||
for column in mapper.columns:
|
||||
name = str(column.key)
|
||||
if name in hidden or name in blocked:
|
||||
continue
|
||||
return name
|
||||
return value_field
|
||||
|
||||
|
||||
def _reference_meta_for_column(table_name: str, column_name: str) -> dict[str, str] | None:
|
||||
detected = _detect_reference_for_column(table_name, column_name)
|
||||
if detected is None:
|
||||
return None
|
||||
ref_table, value_field = detected
|
||||
try:
|
||||
label_field = _reference_label_field(ref_table, value_field)
|
||||
except HTTPException:
|
||||
return None
|
||||
return {
|
||||
"table": ref_table,
|
||||
"value_field": value_field,
|
||||
"label_field": label_field,
|
||||
}
|
||||
|
||||
|
||||
def _default_sort_for_table(model: type) -> list[dict[str, str]]:
|
||||
columns = _columns_map(model)
|
||||
if "sort_order" in columns:
|
||||
return [{"field": "sort_order", "dir": "asc"}]
|
||||
if "created_at" in columns:
|
||||
return [{"field": "created_at", "dir": "desc"}]
|
||||
pk = sa_inspect(model).primary_key
|
||||
if pk:
|
||||
return [{"field": pk[0].key, "dir": "asc"}]
|
||||
return []
|
||||
|
||||
|
||||
def _table_columns_meta(table_name: str, model: type) -> list[dict[str, Any]]:
|
||||
mapper = sa_inspect(model)
|
||||
hidden = _hidden_response_fields(table_name)
|
||||
protected = _protected_input_fields(table_name)
|
||||
primary_keys = {column.key for column in mapper.primary_key}
|
||||
out: list[dict[str, Any]] = []
|
||||
for column in mapper.columns:
|
||||
name = column.key
|
||||
if name in hidden:
|
||||
continue
|
||||
kind = _column_kind(column)
|
||||
has_default = column.default is not None or column.server_default is not None or name in primary_keys
|
||||
editable = name not in SYSTEM_FIELDS and name not in protected and name not in primary_keys
|
||||
item = {
|
||||
"name": name,
|
||||
"label": _column_label(table_name, name),
|
||||
"kind": kind,
|
||||
"nullable": bool(column.nullable),
|
||||
"editable": bool(editable),
|
||||
"sortable": True,
|
||||
"filterable": kind != "json",
|
||||
"required_on_create": not bool(column.nullable) and not bool(has_default) and bool(editable),
|
||||
"has_default": bool(has_default),
|
||||
"is_primary_key": name in primary_keys,
|
||||
}
|
||||
reference = _reference_meta_for_column(table_name, name)
|
||||
if reference is not None:
|
||||
item["reference"] = reference
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _hidden_response_fields(table_name: str) -> set[str]:
|
||||
if table_name == "admin_users":
|
||||
return {"password_hash"}
|
||||
return set()
|
||||
|
||||
|
||||
def _protected_input_fields(table_name: str) -> set[str]:
|
||||
if table_name == "admin_users":
|
||||
return {"password_hash"}
|
||||
if table_name == "requests":
|
||||
return {"client_id", *REQUEST_CALCULATED_FIELDS}
|
||||
if table_name == "invoices":
|
||||
return {"client_id", *INVOICE_CALCULATED_FIELDS}
|
||||
return set()
|
||||
|
||||
def _table_section(table_name: str) -> str:
|
||||
if table_name in {"requests", "invoices", "request_service_requests"}:
|
||||
return "main"
|
||||
if table_name == "table_availability":
|
||||
return "system"
|
||||
return "dictionary"
|
||||
|
||||
|
||||
def _table_availability_map(db: Session) -> dict[str, TableAvailability]:
|
||||
rows = db.query(TableAvailability).all()
|
||||
return {str(row.table_name): row for row in rows if row and row.table_name}
|
||||
|
||||
|
||||
def _table_is_active(table_name: str, availability: dict[str, TableAvailability]) -> bool:
|
||||
row = availability.get(table_name)
|
||||
if row is None:
|
||||
return True
|
||||
return bool(row.is_active)
|
||||
|
||||
|
||||
def _meta_tables_payload(
|
||||
db: Session,
|
||||
*,
|
||||
role: str,
|
||||
include_inactive_dictionaries: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
table_models = _table_model_map()
|
||||
availability = _table_availability_map(db)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for table_name in sorted(table_models.keys()):
|
||||
model = table_models[table_name]
|
||||
section = _table_section(table_name)
|
||||
is_active = _table_is_active(table_name, availability)
|
||||
if section == "dictionary" and not include_inactive_dictionaries and not is_active:
|
||||
continue
|
||||
actions = sorted(_allowed_actions(role, table_name))
|
||||
rows.append(
|
||||
{
|
||||
"key": table_name,
|
||||
"table": table_name,
|
||||
"label": _table_label(table_name),
|
||||
"section": section,
|
||||
"is_active": is_active,
|
||||
"actions": actions,
|
||||
"query_endpoint": f"/api/admin/crud/{table_name}/query",
|
||||
"create_endpoint": f"/api/admin/crud/{table_name}",
|
||||
"update_endpoint_template": f"/api/admin/crud/{table_name}" + "/{id}",
|
||||
"delete_endpoint_template": f"/api/admin/crud/{table_name}" + "/{id}",
|
||||
"default_sort": _default_sort_for_table(model),
|
||||
"columns": _table_columns_meta(table_name, model),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
625
app/api/admin/crud_modules/payloads.py
Normal file
625
app/api/admin/crud_modules/payloads.py
Normal file
|
|
@ -0,0 +1,625 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.inspection import inspect as sa_inspect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.client import Client
|
||||
from app.models.form_field import FormField
|
||||
from app.models.request import Request
|
||||
from app.models.request_data_requirement import RequestDataRequirement
|
||||
from app.models.request_data_template import RequestDataTemplate
|
||||
from app.models.request_data_template_item import RequestDataTemplateItem
|
||||
from app.models.status import Status
|
||||
from app.models.status_group import StatusGroup
|
||||
from app.models.topic import Topic
|
||||
from app.models.topic_data_template import TopicDataTemplate
|
||||
from app.services.billing_flow import normalize_status_kind_or_400
|
||||
|
||||
from .access import ALLOWED_ADMIN_ROLES, ALLOWED_REQUEST_DATA_VALUE_TYPES
|
||||
from .meta import SYSTEM_FIELDS, _columns_map, _protected_input_fields
|
||||
|
||||
def _sanitize_payload(
|
||||
model: type,
|
||||
table_name: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
is_update: bool,
|
||||
allow_protected_fields: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="Тело запроса должно быть JSON-объектом")
|
||||
|
||||
columns = _columns_map(model)
|
||||
allowed_hidden = set(allow_protected_fields or set())
|
||||
mutable_columns = {
|
||||
name
|
||||
for name in columns.keys()
|
||||
if name not in SYSTEM_FIELDS and (name not in _protected_input_fields(table_name) or name in allowed_hidden)
|
||||
}
|
||||
|
||||
unknown_fields = sorted(set(payload.keys()) - mutable_columns)
|
||||
if unknown_fields:
|
||||
raise HTTPException(status_code=400, detail="Неизвестные поля: " + ", ".join(unknown_fields))
|
||||
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in payload.items():
|
||||
column = columns[key]
|
||||
if value is None and not column.nullable:
|
||||
raise HTTPException(status_code=400, detail=f'Поле "{key}" не может быть null')
|
||||
cleaned[key] = value
|
||||
|
||||
if is_update:
|
||||
if not cleaned:
|
||||
raise HTTPException(status_code=400, detail="Нет полей для обновления")
|
||||
return cleaned
|
||||
|
||||
required_missing: list[str] = []
|
||||
for name, column in columns.items():
|
||||
if name in SYSTEM_FIELDS:
|
||||
continue
|
||||
if column.nullable:
|
||||
continue
|
||||
if column.default is not None or column.server_default is not None:
|
||||
continue
|
||||
if name not in cleaned:
|
||||
required_missing.append(name)
|
||||
if required_missing:
|
||||
raise HTTPException(status_code=400, detail="Отсутствуют обязательные поля: " + ", ".join(sorted(required_missing)))
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def _pk_value(model: type, row_id: str) -> Any:
|
||||
pk = sa_inspect(model).primary_key
|
||||
if len(pk) != 1:
|
||||
raise HTTPException(status_code=400, detail="Поддерживаются только таблицы с одним первичным ключом")
|
||||
pk_column = pk[0]
|
||||
try:
|
||||
python_type = pk_column.type.python_type
|
||||
except Exception:
|
||||
python_type = str
|
||||
if python_type is uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(str(row_id))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Некорректный идентификатор")
|
||||
return row_id
|
||||
|
||||
|
||||
def _load_row_or_404(db: Session, model: type, row_id: str):
|
||||
entity = db.get(model, _pk_value(model, row_id))
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Запись не найдена")
|
||||
return entity
|
||||
|
||||
|
||||
def _prepare_create_payload(table_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
if table_name == "requests":
|
||||
track_number = str(data.get("track_number") or "").strip()
|
||||
data["track_number"] = track_number or f"TRK-{uuid.uuid4().hex[:10].upper()}"
|
||||
if data.get("extra_fields") is None:
|
||||
data["extra_fields"] = {}
|
||||
return data
|
||||
|
||||
|
||||
def _normalize_optional_string(value: Any) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _normalize_client_phone(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
allowed = {"+", "(", ")", "-", " "}
|
||||
return "".join(ch for ch in text if ch.isdigit() or ch in allowed).strip()
|
||||
|
||||
|
||||
def _upsert_client_or_400(db: Session, *, full_name: Any, phone: Any, responsible: str) -> Client:
|
||||
normalized_phone = _normalize_client_phone(phone)
|
||||
if not normalized_phone:
|
||||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно')
|
||||
normalized_name = str(full_name or "").strip() or "Клиент"
|
||||
|
||||
row = db.query(Client).filter(Client.phone == normalized_phone).first()
|
||||
if row is None:
|
||||
row = Client(
|
||||
full_name=normalized_name,
|
||||
phone=normalized_phone,
|
||||
responsible=responsible or "Администратор системы",
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
changed = False
|
||||
if normalized_name and row.full_name != normalized_name:
|
||||
row.full_name = normalized_name
|
||||
changed = True
|
||||
if responsible and row.responsible != responsible:
|
||||
row.responsible = responsible
|
||||
changed = True
|
||||
if changed:
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _request_for_uuid_or_400(db: Session, raw_request_id: Any) -> Request:
|
||||
request_uuid = _parse_uuid_or_400(raw_request_id, "request_id")
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=400, detail="Заявка не найдена")
|
||||
return req
|
||||
|
||||
|
||||
def _active_lawyer_or_400(db: Session, lawyer_id: Any) -> AdminUser:
|
||||
lawyer_uuid = _parse_uuid_or_400(lawyer_id, "assigned_lawyer_id")
|
||||
lawyer = db.get(AdminUser, lawyer_uuid)
|
||||
if lawyer is None or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active):
|
||||
raise HTTPException(status_code=400, detail="Можно назначить только активного юриста")
|
||||
return lawyer
|
||||
|
||||
|
||||
def _apply_admin_user_fields_for_create(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
if "password_hash" in data:
|
||||
raise HTTPException(status_code=400, detail='Поле "password_hash" недоступно для записи')
|
||||
raw_password = str(data.pop("password", "")).strip()
|
||||
if not raw_password:
|
||||
raise HTTPException(status_code=400, detail="Пароль обязателен")
|
||||
role = str(data.get("role") or "").strip().upper()
|
||||
if role not in ALLOWED_ADMIN_ROLES:
|
||||
raise HTTPException(status_code=400, detail="Некорректная роль")
|
||||
email = str(data.get("email") or "").strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
data["email"] = email
|
||||
data["role"] = role
|
||||
if "phone" in data:
|
||||
data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone")))
|
||||
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
|
||||
data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code"))
|
||||
data["password_hash"] = hash_password(raw_password)
|
||||
return data
|
||||
|
||||
|
||||
def _apply_admin_user_fields_for_update(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
if "password_hash" in data:
|
||||
raise HTTPException(status_code=400, detail='Поле "password_hash" недоступно для записи')
|
||||
if "password" in data:
|
||||
raw_password = str(data.pop("password") or "").strip()
|
||||
if not raw_password:
|
||||
raise HTTPException(status_code=400, detail="Пароль не может быть пустым")
|
||||
data["password_hash"] = hash_password(raw_password)
|
||||
if "role" in data:
|
||||
role = str(data.get("role") or "").strip().upper()
|
||||
if role not in ALLOWED_ADMIN_ROLES:
|
||||
raise HTTPException(status_code=400, detail="Некорректная роль")
|
||||
data["role"] = role
|
||||
if "email" in data:
|
||||
email = str(data.get("email") or "").strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email не может быть пустым")
|
||||
data["email"] = email
|
||||
if "phone" in data:
|
||||
data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone")))
|
||||
if "avatar_url" in data:
|
||||
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
|
||||
if "primary_topic_code" in data:
|
||||
data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code"))
|
||||
return data
|
||||
|
||||
|
||||
def _parse_uuid_or_400(value: Any, field_name: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть UUID')
|
||||
|
||||
|
||||
def _apply_admin_user_topics_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
if "admin_user_id" in data:
|
||||
user_id = _parse_uuid_or_400(data.get("admin_user_id"), "admin_user_id")
|
||||
user = db.get(AdminUser, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
||||
if str(user.role or "").upper() != "LAWYER":
|
||||
raise HTTPException(status_code=400, detail="Дополнительные темы доступны только для юриста")
|
||||
data["admin_user_id"] = user_id
|
||||
if "topic_code" in data:
|
||||
topic_code = str(data.get("topic_code") or "").strip()
|
||||
if not topic_code:
|
||||
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
|
||||
topic_exists = db.query(Topic.id).filter(Topic.code == topic_code).first()
|
||||
if topic_exists is None:
|
||||
raise HTTPException(status_code=400, detail="Тема не найдена")
|
||||
data["topic_code"] = topic_code
|
||||
return data
|
||||
|
||||
|
||||
def _ensure_topic_exists_or_400(db: Session, topic_code: str) -> None:
|
||||
exists = db.query(Topic.id).filter(Topic.code == topic_code).first()
|
||||
if exists is None:
|
||||
raise HTTPException(status_code=400, detail="Тема не найдена")
|
||||
|
||||
|
||||
def _ensure_form_field_exists_or_400(db: Session, field_key: str) -> None:
|
||||
exists = db.query(FormField.id).filter(FormField.key == field_key).first()
|
||||
if exists is None:
|
||||
raise HTTPException(status_code=400, detail="Поле формы не найдено")
|
||||
|
||||
|
||||
def _ensure_status_exists_or_400(db: Session, status_code: str) -> None:
|
||||
exists = db.query(Status.id).filter(Status.code == status_code).first()
|
||||
if exists is None:
|
||||
raise HTTPException(status_code=400, detail="Статус не найден")
|
||||
|
||||
|
||||
def _as_positive_int_or_400(value: Any, field_name: str) -> int:
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть целым числом')
|
||||
if number <= 0:
|
||||
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть больше 0')
|
||||
return number
|
||||
|
||||
|
||||
def _normalize_string_list_or_400(value: Any, field_name: str) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
source = value
|
||||
if isinstance(source, str):
|
||||
text = source.strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.startswith("["):
|
||||
try:
|
||||
source = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть JSON-массивом строк')
|
||||
else:
|
||||
source = [chunk.strip() for chunk in text.replace("\n", ",").split(",")]
|
||||
|
||||
if not isinstance(source, (list, tuple, set)):
|
||||
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть массивом строк')
|
||||
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in source:
|
||||
text = str(item or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
lowered = text.lower()
|
||||
if lowered in seen:
|
||||
continue
|
||||
seen.add(lowered)
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
def _apply_topic_required_fields_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
if "topic_code" in data:
|
||||
topic_code = str(data.get("topic_code") or "").strip()
|
||||
if not topic_code:
|
||||
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
|
||||
_ensure_topic_exists_or_400(db, topic_code)
|
||||
data["topic_code"] = topic_code
|
||||
if "field_key" in data:
|
||||
field_key = str(data.get("field_key") or "").strip()
|
||||
if not field_key:
|
||||
raise HTTPException(status_code=400, detail='Поле "field_key" не может быть пустым')
|
||||
_ensure_form_field_exists_or_400(db, field_key)
|
||||
data["field_key"] = field_key
|
||||
return data
|
||||
|
||||
|
||||
def _apply_topic_data_templates_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
if "topic_code" in data:
|
||||
topic_code = str(data.get("topic_code") or "").strip()
|
||||
if not topic_code:
|
||||
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
|
||||
_ensure_topic_exists_or_400(db, topic_code)
|
||||
data["topic_code"] = topic_code
|
||||
if "key" in data:
|
||||
key = str(data.get("key") or "").strip()
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
||||
data["key"] = key
|
||||
if "value_type" in data:
|
||||
value_type = str(data.get("value_type") or "").strip().lower()
|
||||
if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
|
||||
raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file')
|
||||
data["value_type"] = value_type
|
||||
if "document_name" in data:
|
||||
data["document_name"] = _normalize_optional_string(data.get("document_name"))
|
||||
return data
|
||||
|
||||
|
||||
def _apply_request_data_templates_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
if "topic_code" in data:
|
||||
topic_code = str(data.get("topic_code") or "").strip()
|
||||
if not topic_code:
|
||||
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
|
||||
_ensure_topic_exists_or_400(db, topic_code)
|
||||
data["topic_code"] = topic_code
|
||||
if "name" in data:
|
||||
name = str(data.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail='Поле "name" не может быть пустым')
|
||||
data["name"] = name
|
||||
if "description" in data:
|
||||
data["description"] = _normalize_optional_string(data.get("description"))
|
||||
if "created_by_admin_id" in data and data.get("created_by_admin_id") is not None:
|
||||
admin_id = _parse_uuid_or_400(data.get("created_by_admin_id"), "created_by_admin_id")
|
||||
admin_user = db.get(AdminUser, admin_id)
|
||||
if admin_user is None:
|
||||
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
||||
data["created_by_admin_id"] = admin_id
|
||||
return data
|
||||
|
||||
|
||||
def _apply_request_data_template_items_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
template = None
|
||||
if "request_data_template_id" in data:
|
||||
template_id = _parse_uuid_or_400(data.get("request_data_template_id"), "request_data_template_id")
|
||||
template = db.get(RequestDataTemplate, template_id)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=400, detail="Шаблон не найден")
|
||||
data["request_data_template_id"] = template_id
|
||||
if "topic_data_template_id" in data and data.get("topic_data_template_id") is not None:
|
||||
catalog_id = _parse_uuid_or_400(data.get("topic_data_template_id"), "topic_data_template_id")
|
||||
catalog = db.get(TopicDataTemplate, catalog_id)
|
||||
if catalog is None:
|
||||
raise HTTPException(status_code=400, detail="Поле доп. данных не найдено")
|
||||
data["topic_data_template_id"] = catalog_id
|
||||
if "key" not in data or not str(data.get("key") or "").strip():
|
||||
data["key"] = str(catalog.key or "").strip()
|
||||
if "label" not in data or not str(data.get("label") or "").strip():
|
||||
data["label"] = str(catalog.label or catalog.key or "").strip()
|
||||
if "value_type" not in data or not str(data.get("value_type") or "").strip():
|
||||
data["value_type"] = str(catalog.value_type or "string")
|
||||
if template is not None and str(template.topic_code or "").strip() and str(catalog.topic_code or "").strip():
|
||||
if str(template.topic_code) != str(catalog.topic_code):
|
||||
raise HTTPException(status_code=400, detail="Поле не соответствует теме шаблона")
|
||||
if "key" in data:
|
||||
key = str(data.get("key") or "").strip()
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
||||
data["key"] = key[:80]
|
||||
if "label" in data:
|
||||
label = str(data.get("label") or "").strip()
|
||||
if not label:
|
||||
raise HTTPException(status_code=400, detail='Поле "label" не может быть пустым')
|
||||
data["label"] = label
|
||||
if "value_type" in data:
|
||||
value_type = str(data.get("value_type") or "").strip().lower()
|
||||
if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
|
||||
raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file')
|
||||
data["value_type"] = value_type
|
||||
if "sort_order" in data:
|
||||
raw = data.get("sort_order")
|
||||
if raw is None or str(raw).strip() == "":
|
||||
data["sort_order"] = 0
|
||||
else:
|
||||
try:
|
||||
data["sort_order"] = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом')
|
||||
return data
|
||||
|
||||
|
||||
def _apply_request_data_requirements_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
if "request_id" in data:
|
||||
request_id = _parse_uuid_or_400(data.get("request_id"), "request_id")
|
||||
request = db.get(Request, request_id)
|
||||
if request is None:
|
||||
raise HTTPException(status_code=400, detail="Заявка не найдена")
|
||||
data["request_id"] = request_id
|
||||
if "topic_template_id" in data and data.get("topic_template_id") is not None:
|
||||
template_id = _parse_uuid_or_400(data.get("topic_template_id"), "topic_template_id")
|
||||
template = db.get(TopicDataTemplate, template_id)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=400, detail="Шаблон темы не найден")
|
||||
data["topic_template_id"] = template_id
|
||||
if "request_message_id" in data and data.get("request_message_id") is not None:
|
||||
data["request_message_id"] = _parse_uuid_or_400(data.get("request_message_id"), "request_message_id")
|
||||
if "key" in data:
|
||||
key = str(data.get("key") or "").strip()
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
||||
data["key"] = key
|
||||
if "field_type" in data:
|
||||
field_type = str(data.get("field_type") or "").strip().lower()
|
||||
if field_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
|
||||
raise HTTPException(status_code=400, detail='Поле "field_type" должно быть одним из: string, text, date, number, file')
|
||||
data["field_type"] = field_type
|
||||
if "document_name" in data:
|
||||
data["document_name"] = _normalize_optional_string(data.get("document_name"))
|
||||
if "value_text" in data:
|
||||
data["value_text"] = _normalize_optional_string(data.get("value_text"))
|
||||
if "sort_order" in data:
|
||||
raw_sort = data.get("sort_order")
|
||||
if raw_sort is None or str(raw_sort).strip() == "":
|
||||
data["sort_order"] = 0
|
||||
else:
|
||||
try:
|
||||
data["sort_order"] = int(raw_sort)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом')
|
||||
return data
|
||||
|
||||
|
||||
def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
topic_code = None
|
||||
from_status = None
|
||||
to_status = None
|
||||
|
||||
if "topic_code" in data:
|
||||
topic_code = str(data.get("topic_code") or "").strip()
|
||||
if not topic_code:
|
||||
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
|
||||
_ensure_topic_exists_or_400(db, topic_code)
|
||||
data["topic_code"] = topic_code
|
||||
if "from_status" in data:
|
||||
from_status = str(data.get("from_status") or "").strip()
|
||||
if not from_status:
|
||||
raise HTTPException(status_code=400, detail='Поле "from_status" не может быть пустым')
|
||||
_ensure_status_exists_or_400(db, from_status)
|
||||
data["from_status"] = from_status
|
||||
if "to_status" in data:
|
||||
to_status = str(data.get("to_status") or "").strip()
|
||||
if not to_status:
|
||||
raise HTTPException(status_code=400, detail='Поле "to_status" не может быть пустым')
|
||||
_ensure_status_exists_or_400(db, to_status)
|
||||
data["to_status"] = to_status
|
||||
|
||||
if from_status and to_status and from_status == to_status:
|
||||
raise HTTPException(status_code=400, detail='Поля "from_status" и "to_status" не должны совпадать')
|
||||
|
||||
if "sla_hours" in data:
|
||||
raw = data.get("sla_hours")
|
||||
if raw is None or str(raw).strip() == "":
|
||||
data["sla_hours"] = None
|
||||
else:
|
||||
data["sla_hours"] = _as_positive_int_or_400(raw, "sla_hours")
|
||||
if "required_data_keys" in data:
|
||||
data["required_data_keys"] = _normalize_string_list_or_400(data.get("required_data_keys"), "required_data_keys")
|
||||
if "required_mime_types" in data:
|
||||
data["required_mime_types"] = _normalize_string_list_or_400(data.get("required_mime_types"), "required_mime_types")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _apply_status_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
if "kind" in data:
|
||||
data["kind"] = normalize_status_kind_or_400(data.get("kind"))
|
||||
if "status_group_id" in data:
|
||||
raw_group = data.get("status_group_id")
|
||||
if raw_group is None or str(raw_group).strip() == "":
|
||||
data["status_group_id"] = None
|
||||
else:
|
||||
group_id = _parse_uuid_or_400(raw_group, "status_group_id")
|
||||
group = db.get(StatusGroup, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=400, detail="Группа статусов не найдена")
|
||||
data["status_group_id"] = group_id
|
||||
if "invoice_template" in data:
|
||||
text = str(data.get("invoice_template") or "").strip()
|
||||
data["invoice_template"] = text or None
|
||||
return data
|
||||
|
||||
|
||||
_RU_TO_LATIN = {
|
||||
"а": "a",
|
||||
"б": "b",
|
||||
"в": "v",
|
||||
"г": "g",
|
||||
"д": "d",
|
||||
"е": "e",
|
||||
"ё": "e",
|
||||
"ж": "zh",
|
||||
"з": "z",
|
||||
"и": "i",
|
||||
"й": "y",
|
||||
"к": "k",
|
||||
"л": "l",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"о": "o",
|
||||
"п": "p",
|
||||
"р": "r",
|
||||
"с": "s",
|
||||
"т": "t",
|
||||
"у": "u",
|
||||
"ф": "f",
|
||||
"х": "h",
|
||||
"ц": "ts",
|
||||
"ч": "ch",
|
||||
"ш": "sh",
|
||||
"щ": "sch",
|
||||
"ъ": "",
|
||||
"ы": "y",
|
||||
"ь": "",
|
||||
"э": "e",
|
||||
"ю": "yu",
|
||||
"я": "ya",
|
||||
}
|
||||
|
||||
|
||||
def _slugify(value: str, fallback: str) -> str:
|
||||
raw = str(value or "").strip().lower()
|
||||
if not raw:
|
||||
return fallback
|
||||
latin = "".join(_RU_TO_LATIN.get(ch, ch) for ch in raw)
|
||||
out: list[str] = []
|
||||
prev_dash = False
|
||||
for ch in latin:
|
||||
if ("a" <= ch <= "z") or ("0" <= ch <= "9"):
|
||||
out.append(ch)
|
||||
prev_dash = False
|
||||
continue
|
||||
if not prev_dash:
|
||||
out.append("-")
|
||||
prev_dash = True
|
||||
slug = "".join(out).strip("-")
|
||||
return slug or fallback
|
||||
|
||||
|
||||
def _make_unique_value(db: Session, model: type, field_name: str, base_value: str) -> str:
|
||||
columns = _columns_map(model)
|
||||
column = columns[field_name]
|
||||
max_len = getattr(column.type, "length", None)
|
||||
base = base_value.strip("-") or field_name
|
||||
if max_len:
|
||||
base = base[:max_len]
|
||||
|
||||
field = getattr(model, field_name)
|
||||
if not db.query(model).filter(field == base).first():
|
||||
return base
|
||||
|
||||
idx = 2
|
||||
while True:
|
||||
suffix = f"-{idx}"
|
||||
candidate = base
|
||||
if max_len and len(candidate) + len(suffix) > max_len:
|
||||
candidate = candidate[: max_len - len(suffix)]
|
||||
candidate = (candidate + suffix).strip("-")
|
||||
if not db.query(model).filter(field == candidate).first():
|
||||
return candidate
|
||||
idx += 1
|
||||
|
||||
|
||||
def _apply_auto_fields_for_create(db: Session, model: type, table_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
if table_name == "topics" and not str(data.get("code") or "").strip():
|
||||
base = _slugify(str(data.get("name") or ""), "topic")
|
||||
data["code"] = _make_unique_value(db, model, "code", base)
|
||||
if table_name == "statuses" and not str(data.get("code") or "").strip():
|
||||
base = _slugify(str(data.get("name") or ""), "status")
|
||||
data["code"] = _make_unique_value(db, model, "code", base)
|
||||
if table_name == "form_fields" and not str(data.get("key") or "").strip():
|
||||
base = _slugify(str(data.get("label") or ""), "field")
|
||||
data["key"] = _make_unique_value(db, model, "key", base)
|
||||
if table_name == "admin_users":
|
||||
data = _apply_admin_user_fields_for_create(data)
|
||||
return data
|
||||
99
app/api/admin/crud_modules/router.py
Normal file
99
app/api/admin/crud_modules/router.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_admin
|
||||
from app.db.session import get_db
|
||||
from app.schemas.universal import UniversalQuery
|
||||
|
||||
from .service import (
|
||||
create_row_service,
|
||||
delete_row_service,
|
||||
get_row_service,
|
||||
list_available_tables_service,
|
||||
list_tables_meta_service,
|
||||
query_table_service,
|
||||
update_available_table_service,
|
||||
update_row_service,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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)):
|
||||
return list_tables_meta_service(db, admin)
|
||||
|
||||
|
||||
@router.get("/meta/available-tables")
|
||||
def list_available_tables(db: Session = Depends(get_db), admin: dict = Depends(get_current_admin)):
|
||||
return list_available_tables_service(db, admin)
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
return update_available_table_service(table_name, payload.is_active, db, admin)
|
||||
|
||||
|
||||
@router.post("/{table_name}/query")
|
||||
def query_table(
|
||||
table_name: str,
|
||||
uq: UniversalQuery,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
return query_table_service(table_name, uq, db, admin)
|
||||
|
||||
|
||||
@router.get("/{table_name}/{row_id}")
|
||||
def get_row(
|
||||
table_name: str,
|
||||
row_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
return get_row_service(table_name, row_id, db, admin)
|
||||
|
||||
|
||||
@router.post("/{table_name}", status_code=201)
|
||||
def create_row(
|
||||
table_name: str,
|
||||
payload: dict[str, Any],
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
return create_row_service(table_name, payload, db, admin)
|
||||
|
||||
|
||||
@router.patch("/{table_name}/{row_id}")
|
||||
def update_row(
|
||||
table_name: str,
|
||||
row_id: str,
|
||||
payload: dict[str, Any],
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
return update_row_service(table_name, row_id, payload, db, admin)
|
||||
|
||||
|
||||
@router.delete("/{table_name}/{row_id}")
|
||||
def delete_row(
|
||||
table_name: str,
|
||||
row_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
return delete_row_service(table_name, row_id, db, admin)
|
||||
552
app/api/admin/crud_modules/service.py
Normal file
552
app/api/admin/crud_modules/service.py
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.message import Message
|
||||
from app.models.request import Request
|
||||
from app.models.request_service_request import RequestServiceRequest
|
||||
from app.models.table_availability import TableAvailability
|
||||
from app.schemas.universal import UniversalQuery
|
||||
from app.services.billing_flow import apply_billing_transition_effects
|
||||
from app.services.notifications import (
|
||||
EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT,
|
||||
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
|
||||
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||||
mark_admin_notifications_read,
|
||||
notify_request_event,
|
||||
)
|
||||
from app.services.request_read_markers import (
|
||||
EVENT_ATTACHMENT,
|
||||
EVENT_MESSAGE,
|
||||
EVENT_STATUS,
|
||||
clear_unread_for_lawyer,
|
||||
mark_unread_for_client,
|
||||
mark_unread_for_lawyer,
|
||||
)
|
||||
from app.services.request_status import apply_status_change_effects
|
||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||
from app.services.status_flow import transition_allowed_for_topic
|
||||
from app.services.status_transition_requirements import validate_transition_requirements_or_400
|
||||
from app.services.universal_query import apply_universal_query
|
||||
|
||||
from .access import (
|
||||
REQUEST_FINANCIAL_FIELDS,
|
||||
_ensure_lawyer_can_manage_request_or_403,
|
||||
_ensure_lawyer_can_view_request_or_403,
|
||||
_is_lawyer,
|
||||
_lawyer_actor_id_or_401,
|
||||
_request_for_related_row_or_404,
|
||||
_require_table_action,
|
||||
_resolve_table_model,
|
||||
)
|
||||
from .audit import _actor_role, _append_audit, _integrity_error, _resolve_responsible, _strip_hidden_fields
|
||||
from .meta import (
|
||||
_columns_map,
|
||||
_meta_tables_payload,
|
||||
_row_to_dict,
|
||||
_serialize_value,
|
||||
_table_availability_map,
|
||||
)
|
||||
from .payloads import (
|
||||
_active_lawyer_or_400,
|
||||
_apply_admin_user_fields_for_update,
|
||||
_apply_admin_user_topics_fields,
|
||||
_apply_auto_fields_for_create,
|
||||
_apply_request_data_requirements_fields,
|
||||
_apply_request_data_template_items_fields,
|
||||
_apply_request_data_templates_fields,
|
||||
_apply_status_fields,
|
||||
_apply_topic_data_templates_fields,
|
||||
_apply_topic_required_fields_fields,
|
||||
_apply_topic_status_transitions_fields,
|
||||
_load_row_or_404,
|
||||
_parse_uuid_or_400,
|
||||
_prepare_create_payload,
|
||||
_request_for_uuid_or_400,
|
||||
_sanitize_payload,
|
||||
_upsert_client_or_400,
|
||||
)
|
||||
|
||||
|
||||
def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin: dict) -> None:
|
||||
if table_name == "messages" and isinstance(row, Message):
|
||||
req = db.get(Request, row.request_id)
|
||||
if req is None:
|
||||
return
|
||||
author_type = str(row.author_type or "").strip().upper()
|
||||
if author_type == "CLIENT":
|
||||
mark_unread_for_lawyer(req, EVENT_MESSAGE)
|
||||
responsible = "Клиент"
|
||||
actor_role = "CLIENT"
|
||||
actor_admin_user_id = None
|
||||
else:
|
||||
mark_unread_for_client(req, EVENT_MESSAGE)
|
||||
responsible = _resolve_responsible(admin)
|
||||
actor_role = _actor_role(admin)
|
||||
actor_admin_user_id = admin.get("sub")
|
||||
req.responsible = responsible
|
||||
db.add(req)
|
||||
notify_request_event(
|
||||
db,
|
||||
request=req,
|
||||
event_type=NOTIFICATION_EVENT_MESSAGE,
|
||||
actor_role=actor_role,
|
||||
actor_admin_user_id=actor_admin_user_id,
|
||||
body=str(row.body or "").strip() or None,
|
||||
responsible=responsible,
|
||||
)
|
||||
return
|
||||
|
||||
if table_name == "attachments" and isinstance(row, Attachment):
|
||||
req = db.get(Request, row.request_id)
|
||||
if req is None:
|
||||
return
|
||||
mark_unread_for_client(req, EVENT_ATTACHMENT)
|
||||
responsible = _resolve_responsible(admin)
|
||||
req.responsible = responsible
|
||||
db.add(req)
|
||||
notify_request_event(
|
||||
db,
|
||||
request=req,
|
||||
event_type=NOTIFICATION_EVENT_ATTACHMENT,
|
||||
actor_role=_actor_role(admin),
|
||||
actor_admin_user_id=admin.get("sub"),
|
||||
body=f"Файл: {row.file_name}",
|
||||
responsible=responsible,
|
||||
)
|
||||
|
||||
|
||||
def list_tables_meta_service(db: Session, admin: dict) -> dict[str, Any]:
|
||||
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)}
|
||||
|
||||
|
||||
def list_available_tables_service(db: Session, admin: dict) -> dict[str, Any]:
|
||||
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)}
|
||||
|
||||
|
||||
def update_available_table_service(table_name: str, is_active: bool, db: Session, admin: dict) -> dict[str, Any]:
|
||||
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)
|
||||
next_is_active = bool(is_active)
|
||||
if row is None:
|
||||
row = TableAvailability(
|
||||
table_name=normalized,
|
||||
is_active=next_is_active,
|
||||
responsible=responsible,
|
||||
)
|
||||
db.add(row)
|
||||
else:
|
||||
row.is_active = next_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),
|
||||
}
|
||||
|
||||
|
||||
def query_table_service(table_name: str, uq: UniversalQuery, db: Session, admin: dict) -> dict[str, Any]:
|
||||
normalized, model = _resolve_table_model(table_name)
|
||||
_require_table_action(admin, normalized, "query")
|
||||
base_query = db.query(model)
|
||||
if normalized == "requests" and _is_lawyer(admin):
|
||||
actor_id = _lawyer_actor_id_or_401(admin)
|
||||
base_query = base_query.filter(
|
||||
or_(
|
||||
Request.assigned_lawyer_id == actor_id,
|
||||
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),
|
||||
)
|
||||
)
|
||||
if normalized == "request_service_requests" and _is_lawyer(admin):
|
||||
actor_id = _lawyer_actor_id_or_401(admin)
|
||||
base_query = base_query.filter(
|
||||
RequestServiceRequest.type == "CURATOR_CONTACT",
|
||||
RequestServiceRequest.assigned_lawyer_id == actor_id,
|
||||
)
|
||||
query = apply_universal_query(base_query, model, uq)
|
||||
total = query.count()
|
||||
rows = query.offset(uq.page.offset).limit(uq.page.limit).all()
|
||||
return {"rows": [_strip_hidden_fields(normalized, _row_to_dict(row)) for row in rows], "total": total}
|
||||
|
||||
|
||||
def get_row_service(table_name: str, row_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||
normalized, model = _resolve_table_model(table_name)
|
||||
_require_table_action(admin, normalized, "read")
|
||||
row = _load_row_or_404(db, model, row_id)
|
||||
if normalized == "requests":
|
||||
req = row if isinstance(row, Request) else None
|
||||
if req is not None:
|
||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
changed = False
|
||||
if _is_lawyer(admin) and clear_unread_for_lawyer(req):
|
||||
changed = True
|
||||
db.add(req)
|
||||
read_count = mark_admin_notifications_read(
|
||||
db,
|
||||
admin_user_id=admin.get("sub"),
|
||||
request_id=req.id,
|
||||
responsible=_resolve_responsible(admin),
|
||||
)
|
||||
if read_count:
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
db.refresh(req)
|
||||
row = req
|
||||
if normalized == "messages" and isinstance(row, Message):
|
||||
req = _request_for_related_row_or_404(db, row)
|
||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
if normalized == "attachments" and isinstance(row, Attachment):
|
||||
req = _request_for_related_row_or_404(db, row)
|
||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
if normalized == "request_service_requests" and _is_lawyer(admin):
|
||||
actor_id = _lawyer_actor_id_or_401(admin)
|
||||
row_type = str(getattr(row, "type", "") or "").strip().upper()
|
||||
assigned = str(getattr(row, "assigned_lawyer_id", "") or "").strip()
|
||||
if row_type != "CURATOR_CONTACT" or not assigned or assigned != actor_id:
|
||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||
payload = _strip_hidden_fields(normalized, _row_to_dict(row))
|
||||
if normalized == "requests" and isinstance(row, Request):
|
||||
assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
|
||||
if assigned_lawyer_id:
|
||||
try:
|
||||
lawyer_uuid = uuid.UUID(assigned_lawyer_id)
|
||||
except ValueError:
|
||||
lawyer_uuid = None
|
||||
if lawyer_uuid is not None:
|
||||
lawyer = db.get(AdminUser, lawyer_uuid)
|
||||
if lawyer is not None:
|
||||
payload["assigned_lawyer_name"] = lawyer.name or lawyer.email or assigned_lawyer_id
|
||||
payload["assigned_lawyer_phone"] = _serialize_value(getattr(lawyer, "phone", None))
|
||||
return payload
|
||||
|
||||
|
||||
def create_row_service(table_name: str, payload: dict[str, Any], db: Session, admin: dict) -> dict[str, Any]:
|
||||
normalized, model = _resolve_table_model(table_name)
|
||||
_require_table_action(admin, normalized, "create")
|
||||
responsible = _resolve_responsible(admin)
|
||||
resolved_request_client_id: uuid.UUID | None = None
|
||||
resolved_invoice_client_id: uuid.UUID | None = None
|
||||
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
|
||||
assigned_lawyer_id = payload.get("assigned_lawyer_id")
|
||||
if str(assigned_lawyer_id or "").strip():
|
||||
raise HTTPException(status_code=403, detail='Юрист не может назначать заявку при создании')
|
||||
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys())))
|
||||
if forbidden_fields:
|
||||
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||||
|
||||
prepared = _prepare_create_payload(normalized, payload)
|
||||
if normalized == "messages":
|
||||
request_uuid = _parse_uuid_or_400(prepared.get("request_id"), "request_id")
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
if _is_lawyer(admin):
|
||||
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
prepared["author_type"] = "LAWYER"
|
||||
prepared["author_name"] = str(admin.get("email") or "").strip() or "Юрист"
|
||||
prepared["immutable"] = False
|
||||
prepared["request_id"] = request_uuid
|
||||
if normalized == "requests":
|
||||
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
|
||||
client = _upsert_client_or_400(
|
||||
db,
|
||||
full_name=prepared.get("client_name"),
|
||||
phone=prepared.get("client_phone"),
|
||||
responsible=responsible,
|
||||
)
|
||||
resolved_request_client_id = client.id
|
||||
prepared["client_name"] = client.full_name
|
||||
prepared["client_phone"] = client.phone
|
||||
if not _is_lawyer(admin):
|
||||
assigned_raw = prepared.get("assigned_lawyer_id")
|
||||
if assigned_raw is None or not str(assigned_raw).strip():
|
||||
if "assigned_lawyer_id" in prepared:
|
||||
prepared["assigned_lawyer_id"] = None
|
||||
else:
|
||||
assigned_lawyer = _active_lawyer_or_400(db, assigned_raw)
|
||||
prepared["assigned_lawyer_id"] = str(assigned_lawyer.id)
|
||||
if prepared.get("effective_rate") is None:
|
||||
prepared["effective_rate"] = assigned_lawyer.default_rate
|
||||
if normalized == "invoices":
|
||||
req = _request_for_uuid_or_400(db, prepared.get("request_id"))
|
||||
prepared["request_id"] = req.id
|
||||
resolved_invoice_client_id = req.client_id
|
||||
prepared = _apply_auto_fields_for_create(db, model, normalized, prepared)
|
||||
clean_payload = _sanitize_payload(
|
||||
model,
|
||||
normalized,
|
||||
prepared,
|
||||
is_update=False,
|
||||
allow_protected_fields={"password_hash"} if normalized == "admin_users" else None,
|
||||
)
|
||||
if normalized == "admin_user_topics":
|
||||
clean_payload = _apply_admin_user_topics_fields(db, clean_payload)
|
||||
if normalized == "topic_required_fields":
|
||||
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
|
||||
if normalized == "topic_data_templates":
|
||||
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
|
||||
if normalized == "request_data_templates":
|
||||
clean_payload = _apply_request_data_templates_fields(db, clean_payload)
|
||||
if normalized == "request_data_template_items":
|
||||
clean_payload = _apply_request_data_template_items_fields(db, clean_payload)
|
||||
if normalized == "request_data_requirements":
|
||||
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
||||
if normalized == "topic_status_transitions":
|
||||
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||
if normalized == "statuses":
|
||||
clean_payload = _apply_status_fields(db, clean_payload)
|
||||
if normalized == "requests":
|
||||
clean_payload["client_id"] = resolved_request_client_id
|
||||
if normalized == "invoices":
|
||||
clean_payload["client_id"] = resolved_invoice_client_id
|
||||
if "responsible" in _columns_map(model):
|
||||
clean_payload["responsible"] = responsible
|
||||
row = model(**clean_payload)
|
||||
|
||||
try:
|
||||
db.add(row)
|
||||
db.flush()
|
||||
_apply_create_side_effects(db, table_name=normalized, row=row, admin=admin)
|
||||
snapshot = _row_to_dict(row)
|
||||
_append_audit(db, admin, normalized, str(snapshot.get("id") or ""), "CREATE", {"after": snapshot})
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise _integrity_error()
|
||||
|
||||
return _strip_hidden_fields(normalized, _row_to_dict(row))
|
||||
|
||||
|
||||
def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db: Session, admin: dict) -> dict[str, Any]:
|
||||
normalized, model = _resolve_table_model(table_name)
|
||||
_require_table_action(admin, normalized, "update")
|
||||
responsible = _resolve_responsible(admin)
|
||||
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
|
||||
if "assigned_lawyer_id" in payload:
|
||||
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
||||
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys())))
|
||||
if forbidden_fields:
|
||||
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||||
row = _load_row_or_404(db, model, row_id)
|
||||
if normalized == "requests" and isinstance(row, Request):
|
||||
_ensure_lawyer_can_manage_request_or_403(admin, row)
|
||||
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
|
||||
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования")
|
||||
prepared = dict(payload)
|
||||
if normalized == "admin_users":
|
||||
prepared = _apply_admin_user_fields_for_update(prepared)
|
||||
clean_payload = _sanitize_payload(
|
||||
model,
|
||||
normalized,
|
||||
prepared,
|
||||
is_update=True,
|
||||
allow_protected_fields={"password_hash"} if normalized == "admin_users" else None,
|
||||
)
|
||||
if normalized == "admin_user_topics":
|
||||
clean_payload = _apply_admin_user_topics_fields(db, clean_payload)
|
||||
if normalized == "topic_required_fields":
|
||||
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
|
||||
if normalized == "topic_data_templates":
|
||||
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
|
||||
if normalized == "request_data_templates":
|
||||
clean_payload = _apply_request_data_templates_fields(db, clean_payload)
|
||||
if normalized == "request_data_template_items":
|
||||
clean_payload = _apply_request_data_template_items_fields(db, clean_payload)
|
||||
if normalized == "request_data_requirements":
|
||||
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
||||
if normalized == "topic_status_transitions":
|
||||
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||
if normalized == "statuses":
|
||||
clean_payload = _apply_status_fields(db, clean_payload)
|
||||
if normalized == "requests" and isinstance(row, Request):
|
||||
if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None:
|
||||
client = _upsert_client_or_400(
|
||||
db,
|
||||
full_name=clean_payload.get("client_name", row.client_name),
|
||||
phone=clean_payload.get("client_phone", row.client_phone),
|
||||
responsible=responsible,
|
||||
)
|
||||
clean_payload["client_id"] = client.id
|
||||
clean_payload["client_name"] = client.full_name
|
||||
clean_payload["client_phone"] = client.phone
|
||||
if normalized == "invoices":
|
||||
if "request_id" in clean_payload:
|
||||
req = _request_for_uuid_or_400(db, clean_payload.get("request_id"))
|
||||
clean_payload["request_id"] = req.id
|
||||
clean_payload["client_id"] = req.client_id
|
||||
elif getattr(row, "client_id", None) is None:
|
||||
req = db.get(Request, getattr(row, "request_id", None))
|
||||
if req is not None:
|
||||
clean_payload["client_id"] = req.client_id
|
||||
if normalized == "requests" and not _is_lawyer(admin) and "assigned_lawyer_id" in clean_payload:
|
||||
assigned_raw = clean_payload.get("assigned_lawyer_id")
|
||||
if assigned_raw is None or not str(assigned_raw).strip():
|
||||
clean_payload["assigned_lawyer_id"] = None
|
||||
else:
|
||||
assigned_lawyer = _active_lawyer_or_400(db, assigned_raw)
|
||||
clean_payload["assigned_lawyer_id"] = str(assigned_lawyer.id)
|
||||
if isinstance(row, Request) and row.effective_rate is None and "effective_rate" not in clean_payload:
|
||||
clean_payload["effective_rate"] = assigned_lawyer.default_rate
|
||||
if "responsible" in _columns_map(model):
|
||||
clean_payload["responsible"] = responsible
|
||||
before = _row_to_dict(row)
|
||||
if normalized == "topic_status_transitions":
|
||||
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
|
||||
next_to = str(clean_payload.get("to_status", before.get("to_status") or "")).strip()
|
||||
if next_from and next_to and next_from == next_to:
|
||||
raise HTTPException(status_code=400, detail='Поля "from_status" и "to_status" не должны совпадать')
|
||||
if normalized == "requests" and "status_code" in clean_payload:
|
||||
before_status = str(before.get("status_code") or "")
|
||||
after_status = str(clean_payload.get("status_code") or "")
|
||||
if before_status != after_status and isinstance(row, Request):
|
||||
if not transition_allowed_for_topic(
|
||||
db,
|
||||
str(row.topic_code or "").strip() or None,
|
||||
before_status,
|
||||
after_status,
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
|
||||
extra_fields_override = clean_payload.get("extra_fields")
|
||||
validate_transition_requirements_or_400(
|
||||
db,
|
||||
row,
|
||||
before_status,
|
||||
after_status,
|
||||
extra_fields_override=extra_fields_override if isinstance(extra_fields_override, dict) else None,
|
||||
)
|
||||
if "important_date_at" not in clean_payload or clean_payload.get("important_date_at") is None:
|
||||
clean_payload["important_date_at"] = datetime.now(timezone.utc) + timedelta(days=3)
|
||||
billing_note = apply_billing_transition_effects(
|
||||
db,
|
||||
req=row,
|
||||
from_status=before_status,
|
||||
to_status=after_status,
|
||||
admin=admin,
|
||||
responsible=responsible,
|
||||
)
|
||||
mark_unread_for_client(row, EVENT_STATUS)
|
||||
apply_status_change_effects(
|
||||
db,
|
||||
row,
|
||||
from_status=before_status,
|
||||
to_status=after_status,
|
||||
admin=admin,
|
||||
important_date_at=clean_payload.get("important_date_at"),
|
||||
responsible=responsible,
|
||||
)
|
||||
notify_request_event(
|
||||
db,
|
||||
request=row,
|
||||
event_type=NOTIFICATION_EVENT_STATUS,
|
||||
actor_role=_actor_role(admin),
|
||||
actor_admin_user_id=admin.get("sub"),
|
||||
body=(
|
||||
f"{before_status} -> {after_status}"
|
||||
+ (
|
||||
f"\nВажная дата: {clean_payload.get('important_date_at').isoformat()}"
|
||||
if isinstance(clean_payload.get("important_date_at"), datetime)
|
||||
else ""
|
||||
)
|
||||
+ (f"\n{billing_note}" if billing_note else "")
|
||||
),
|
||||
responsible=responsible,
|
||||
)
|
||||
for key, value in clean_payload.items():
|
||||
setattr(row, key, value)
|
||||
|
||||
try:
|
||||
db.add(row)
|
||||
db.flush()
|
||||
after = _row_to_dict(row)
|
||||
_append_audit(db, admin, normalized, str(after.get("id") or row_id), "UPDATE", {"before": before, "after": after})
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise _integrity_error()
|
||||
|
||||
return _strip_hidden_fields(normalized, _row_to_dict(row))
|
||||
|
||||
|
||||
def delete_row_service(table_name: str, row_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||
normalized, model = _resolve_table_model(table_name)
|
||||
_require_table_action(admin, normalized, "delete")
|
||||
if normalized == "admin_users" and str(admin.get("sub") or "") == str(row_id):
|
||||
raise HTTPException(status_code=400, detail="Нельзя удалить собственную учетную запись")
|
||||
row = _load_row_or_404(db, model, row_id)
|
||||
if normalized == "requests" and isinstance(row, Request):
|
||||
_ensure_lawyer_can_manage_request_or_403(admin, row)
|
||||
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
|
||||
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для удаления")
|
||||
|
||||
before = _row_to_dict(row)
|
||||
entity_id = str(before.get("id") or row_id)
|
||||
|
||||
try:
|
||||
db.delete(row)
|
||||
_append_audit(db, admin, normalized, entity_id, "DELETE", {"before": before})
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise _integrity_error("Невозможно удалить запись из-за ограничений связанных данных")
|
||||
|
||||
return {"status": "удалено", "id": entity_id}
|
||||
|
|
@ -13,6 +13,7 @@ from app.db.session import get_db
|
|||
from app.models.admin_user import AdminUser
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.request import Request
|
||||
from app.models.request_service_request import RequestServiceRequest
|
||||
from app.models.status import Status
|
||||
from app.models.status_history import StatusHistory
|
||||
from app.services.sla_metrics import compute_sla_snapshot
|
||||
|
|
@ -88,7 +89,7 @@ def _extract_assigned_lawyer_from_audit(diff: dict | None, action: str | None) -
|
|||
|
||||
|
||||
@router.get("/overview")
|
||||
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
||||
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))):
|
||||
role = str(admin.get("role") or "").upper()
|
||||
actor_id = str(admin.get("sub") or "").strip()
|
||||
actor_uuid = _uuid_or_none(actor_id)
|
||||
|
|
@ -110,6 +111,26 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
|||
.scalar()
|
||||
or 0
|
||||
)
|
||||
if role == "LAWYER" and actor_uuid is not None:
|
||||
service_request_unread_total = int(
|
||||
db.query(func.count(RequestServiceRequest.id))
|
||||
.filter(
|
||||
RequestServiceRequest.type == "CURATOR_CONTACT",
|
||||
RequestServiceRequest.assigned_lawyer_id == str(actor_uuid),
|
||||
RequestServiceRequest.lawyer_unread.is_(True),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
elif role == "LAWYER":
|
||||
service_request_unread_total = 0
|
||||
else:
|
||||
service_request_unread_total = int(
|
||||
db.query(func.count(RequestServiceRequest.id))
|
||||
.filter(RequestServiceRequest.admin_unread.is_(True))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
active_load_rows = (
|
||||
db.query(Request.assigned_lawyer_id, func.count(Request.id))
|
||||
|
|
@ -290,7 +311,7 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
|||
deadline_alert_query = deadline_alert_query.filter(Request.id.is_(None))
|
||||
deadline_alert_total = int(deadline_alert_query.scalar() or 0)
|
||||
return {
|
||||
"scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN",
|
||||
"scope": role if role in {"ADMIN", "LAWYER", "CURATOR"} else "ADMIN",
|
||||
"new": int(by_status.get("NEW", 0)),
|
||||
"by_status": by_status,
|
||||
"assigned_total": assigned_total,
|
||||
|
|
@ -310,6 +331,7 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
|||
"avg_time_in_status_hours": sla_snapshot.get("avg_time_in_status_hours", {}),
|
||||
"unread_for_clients": int(unread_for_clients),
|
||||
"unread_for_lawyers": int(unread_for_lawyers),
|
||||
"service_request_unread_total": int(service_request_unread_total),
|
||||
"lawyer_loads": scoped_lawyer_loads,
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
3
app/api/admin/requests_modules/__init__.py
Normal file
3
app/api/admin/requests_modules/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
29
app/api/admin/requests_modules/common.py
Normal file
29
app/api/admin/requests_modules/common.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
def parse_datetime_safe(value: object) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def normalize_important_date_or_default(raw: object, *, default_days: int = 3) -> datetime:
|
||||
parsed = parse_datetime_safe(raw)
|
||||
if parsed:
|
||||
return parsed
|
||||
return datetime.now(timezone.utc) + timedelta(days=default_days)
|
||||
233
app/api/admin/requests_modules/data_templates.py
Normal file
233
app/api/admin/requests_modules/data_templates.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.request import Request
|
||||
from app.models.request_data_requirement import RequestDataRequirement
|
||||
from app.models.topic_data_template import TopicDataTemplate
|
||||
from app.schemas.admin import RequestDataRequirementCreate, RequestDataRequirementPatch
|
||||
from app.services.request_status import actor_admin_uuid
|
||||
|
||||
from .permissions import ensure_lawyer_can_manage_request_or_403, request_uuid_or_400
|
||||
|
||||
|
||||
def request_data_requirement_row(row: RequestDataRequirement) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"request_id": str(row.request_id),
|
||||
"topic_template_id": str(row.topic_template_id) if row.topic_template_id else None,
|
||||
"key": row.key,
|
||||
"label": row.label,
|
||||
"description": row.description,
|
||||
"required": bool(row.required),
|
||||
"created_by_admin_id": str(row.created_by_admin_id) if row.created_by_admin_id else None,
|
||||
"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 get_request_data_template_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
|
||||
topic_items = (
|
||||
db.query(TopicDataTemplate)
|
||||
.filter(
|
||||
TopicDataTemplate.topic_code == str(req.topic_code or ""),
|
||||
TopicDataTemplate.enabled.is_(True),
|
||||
)
|
||||
.order_by(TopicDataTemplate.sort_order.asc(), TopicDataTemplate.key.asc())
|
||||
.all()
|
||||
)
|
||||
request_items = (
|
||||
db.query(RequestDataRequirement)
|
||||
.filter(RequestDataRequirement.request_id == req.id)
|
||||
.order_by(RequestDataRequirement.created_at.asc(), RequestDataRequirement.key.asc())
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"request_id": str(req.id),
|
||||
"topic_code": req.topic_code,
|
||||
"topic_items": [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"key": row.key,
|
||||
"label": row.label,
|
||||
"description": row.description,
|
||||
"required": bool(row.required),
|
||||
"sort_order": row.sort_order,
|
||||
}
|
||||
for row in topic_items
|
||||
],
|
||||
"request_items": [request_data_requirement_row(row) for row in request_items],
|
||||
}
|
||||
|
||||
|
||||
def sync_request_data_template_from_topic_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
topic_code = str(req.topic_code or "").strip()
|
||||
if not topic_code:
|
||||
return {"status": "ok", "created": 0, "request_id": str(req.id)}
|
||||
|
||||
topic_items = (
|
||||
db.query(TopicDataTemplate)
|
||||
.filter(
|
||||
TopicDataTemplate.topic_code == topic_code,
|
||||
TopicDataTemplate.enabled.is_(True),
|
||||
)
|
||||
.order_by(TopicDataTemplate.sort_order.asc(), TopicDataTemplate.key.asc())
|
||||
.all()
|
||||
)
|
||||
existing_keys = {
|
||||
str(key).strip()
|
||||
for (key,) in db.query(RequestDataRequirement.key).filter(RequestDataRequirement.request_id == req.id).all()
|
||||
if key
|
||||
}
|
||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||
actor_id = actor_admin_uuid(admin)
|
||||
|
||||
created = 0
|
||||
for template in topic_items:
|
||||
key = str(template.key or "").strip()
|
||||
if not key or key in existing_keys:
|
||||
continue
|
||||
db.add(
|
||||
RequestDataRequirement(
|
||||
request_id=req.id,
|
||||
topic_template_id=template.id,
|
||||
key=key,
|
||||
label=template.label,
|
||||
description=template.description,
|
||||
required=bool(template.required),
|
||||
created_by_admin_id=actor_id,
|
||||
responsible=responsible,
|
||||
)
|
||||
)
|
||||
existing_keys.add(key)
|
||||
created += 1
|
||||
|
||||
db.commit()
|
||||
return {"status": "ok", "created": created, "request_id": str(req.id)}
|
||||
|
||||
|
||||
def create_request_data_requirement_service(
|
||||
request_id: str,
|
||||
payload: RequestDataRequirementCreate,
|
||||
db: Session,
|
||||
admin: dict,
|
||||
) -> dict[str, Any]:
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
|
||||
key = str(payload.key or "").strip()
|
||||
label = str(payload.label or "").strip()
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail='Поле "key" обязательно')
|
||||
if not label:
|
||||
raise HTTPException(status_code=400, detail='Поле "label" обязательно')
|
||||
|
||||
exists = (
|
||||
db.query(RequestDataRequirement.id)
|
||||
.filter(RequestDataRequirement.request_id == req.id, RequestDataRequirement.key == key)
|
||||
.first()
|
||||
)
|
||||
if exists is not None:
|
||||
raise HTTPException(status_code=400, detail="Элемент с таким key уже существует в шаблоне заявки")
|
||||
|
||||
row = RequestDataRequirement(
|
||||
request_id=req.id,
|
||||
topic_template_id=None,
|
||||
key=key,
|
||||
label=label,
|
||||
description=payload.description,
|
||||
required=bool(payload.required),
|
||||
created_by_admin_id=actor_admin_uuid(admin),
|
||||
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return request_data_requirement_row(row)
|
||||
|
||||
|
||||
def update_request_data_requirement_service(
|
||||
request_id: str,
|
||||
item_id: str,
|
||||
payload: RequestDataRequirementPatch,
|
||||
db: Session,
|
||||
admin: dict,
|
||||
) -> dict[str, Any]:
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
|
||||
item_uuid = request_uuid_or_400(item_id)
|
||||
row = db.get(RequestDataRequirement, item_uuid)
|
||||
if row is None or row.request_id != req.id:
|
||||
raise HTTPException(status_code=404, detail="Элемент шаблона заявки не найден")
|
||||
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail="Нет полей для обновления")
|
||||
if "key" in changes:
|
||||
key = str(changes.get("key") or "").strip()
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
||||
duplicate = (
|
||||
db.query(RequestDataRequirement.id)
|
||||
.filter(
|
||||
RequestDataRequirement.request_id == req.id,
|
||||
RequestDataRequirement.key == key,
|
||||
RequestDataRequirement.id != row.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise HTTPException(status_code=400, detail="Элемент с таким key уже существует в шаблоне заявки")
|
||||
row.key = key
|
||||
if "label" in changes:
|
||||
label = str(changes.get("label") or "").strip()
|
||||
if not label:
|
||||
raise HTTPException(status_code=400, detail='Поле "label" не может быть пустым')
|
||||
row.label = label
|
||||
if "description" in changes:
|
||||
row.description = changes.get("description")
|
||||
if "required" in changes:
|
||||
row.required = bool(changes.get("required"))
|
||||
row.responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return request_data_requirement_row(row)
|
||||
|
||||
|
||||
def delete_request_data_requirement_service(request_id: str, item_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||
|
||||
item_uuid = request_uuid_or_400(item_id)
|
||||
row = db.get(RequestDataRequirement, item_uuid)
|
||||
if row is None or row.request_id != req.id:
|
||||
raise HTTPException(status_code=404, detail="Элемент шаблона заявки не найден")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"status": "удалено", "id": str(row.id)}
|
||||
519
app/api/admin/requests_modules/kanban.py
Normal file
519
app/api/admin/requests_modules/kanban.py
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.request import Request
|
||||
from app.models.status import Status
|
||||
from app.models.status_group import StatusGroup
|
||||
from app.models.status_history import StatusHistory
|
||||
from app.models.topic_status_transition import TopicStatusTransition
|
||||
from app.schemas.universal import FilterClause, Page, UniversalQuery
|
||||
from app.services.universal_query import apply_universal_query
|
||||
|
||||
from .common import parse_datetime_safe
|
||||
|
||||
ALLOWED_KANBAN_FILTER_FIELDS = {"assigned_lawyer_id", "client_name", "status_code", "created_at", "topic_code", "overdue"}
|
||||
ALLOWED_KANBAN_SORT_MODES = {"created_newest", "lawyer", "deadline"}
|
||||
FALLBACK_KANBAN_GROUPS = [
|
||||
("fallback_new", "Новые", 10),
|
||||
("fallback_in_progress", "В работе", 20),
|
||||
("fallback_waiting", "Ожидание", 30),
|
||||
("fallback_done", "Завершены", 40),
|
||||
]
|
||||
|
||||
|
||||
def status_meta_or_default(meta_map: dict[str, dict[str, object]], status_code: str) -> dict[str, object]:
|
||||
return meta_map.get(status_code) or {
|
||||
"name": status_code,
|
||||
"kind": "DEFAULT",
|
||||
"is_terminal": False,
|
||||
"status_group_id": None,
|
||||
"status_group_name": None,
|
||||
"status_group_order": None,
|
||||
}
|
||||
|
||||
|
||||
def fallback_group_for_status(status_code: str, status_meta: dict[str, object]) -> tuple[str, str, int]:
|
||||
code = str(status_code or "").strip().upper()
|
||||
kind = str(status_meta.get("kind") or "DEFAULT").upper()
|
||||
name = str(status_meta.get("name") or "").upper()
|
||||
is_terminal = bool(status_meta.get("is_terminal"))
|
||||
|
||||
if is_terminal:
|
||||
return FALLBACK_KANBAN_GROUPS[3]
|
||||
if kind == "PAID":
|
||||
return FALLBACK_KANBAN_GROUPS[3]
|
||||
if code.startswith("NEW") or "НОВ" in name:
|
||||
return FALLBACK_KANBAN_GROUPS[0]
|
||||
waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK")
|
||||
waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД")
|
||||
if kind == "INVOICE":
|
||||
return FALLBACK_KANBAN_GROUPS[2]
|
||||
if any(token in code for token in waiting_tokens) or any(token in name for token in waiting_ru_tokens):
|
||||
return FALLBACK_KANBAN_GROUPS[2]
|
||||
done_tokens = ("CLOSE", "RESOLV", "REJECT", "DONE", "PAID")
|
||||
done_ru_tokens = ("ЗАВЕРШ", "ЗАКРЫ", "РЕШЕН", "ОТКЛОН", "ОПЛАЧ")
|
||||
if any(token in code for token in done_tokens) or any(token in name for token in done_ru_tokens):
|
||||
return FALLBACK_KANBAN_GROUPS[3]
|
||||
return FALLBACK_KANBAN_GROUPS[1]
|
||||
|
||||
|
||||
def extract_case_deadline(extra_fields: object) -> datetime | None:
|
||||
if not isinstance(extra_fields, dict):
|
||||
return None
|
||||
deadline_keys = (
|
||||
"deadline_at",
|
||||
"deadline",
|
||||
"due_date",
|
||||
"due_at",
|
||||
"case_deadline",
|
||||
"court_date",
|
||||
"hearing_date",
|
||||
"next_action_deadline",
|
||||
)
|
||||
for key in deadline_keys:
|
||||
parsed = parse_datetime_safe(extra_fields.get(key))
|
||||
if parsed:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def coerce_kanban_bool(value: object) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
text = str(value or "").strip().lower()
|
||||
if text in {"1", "true", "yes", "y", "on"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "n", "off"}:
|
||||
return False
|
||||
raise HTTPException(status_code=400, detail='Поле "overdue" должно быть boolean')
|
||||
|
||||
|
||||
def parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterClause], list[tuple[str, bool]]]:
|
||||
if not raw_filters:
|
||||
return [], []
|
||||
try:
|
||||
parsed = json.loads(raw_filters)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail="Некорректный JSON фильтров канбана") from exc
|
||||
if not isinstance(parsed, list):
|
||||
raise HTTPException(status_code=400, detail="Фильтры канбана должны быть массивом")
|
||||
|
||||
universal_filters: list[FilterClause] = []
|
||||
overdue_filters: list[tuple[str, bool]] = []
|
||||
for index, item in enumerate(parsed):
|
||||
if not isinstance(item, dict):
|
||||
raise HTTPException(status_code=400, detail=f"Фильтр #{index + 1} должен быть объектом")
|
||||
field = str(item.get("field") or "").strip()
|
||||
op = str(item.get("op") or "").strip()
|
||||
value = item.get("value")
|
||||
if field not in ALLOWED_KANBAN_FILTER_FIELDS:
|
||||
raise HTTPException(status_code=400, detail=f'Недоступное поле фильтра: "{field}"')
|
||||
if op not in {"=", "!=", ">", "<", ">=", "<=", "~"}:
|
||||
raise HTTPException(status_code=400, detail=f'Недопустимый оператор фильтра: "{op}"')
|
||||
if field == "overdue":
|
||||
if op not in {"=", "!="}:
|
||||
raise HTTPException(status_code=400, detail='Для поля "overdue" доступны только операторы "=" и "!="')
|
||||
overdue_filters.append((op, coerce_kanban_bool(value)))
|
||||
continue
|
||||
universal_filters.append(FilterClause(field=field, op=op, value=value))
|
||||
return universal_filters, overdue_filters
|
||||
|
||||
|
||||
def apply_overdue_filters(items: list[dict[str, object]], overdue_filters: list[tuple[str, bool]]) -> list[dict[str, object]]:
|
||||
if not overdue_filters:
|
||||
return items
|
||||
now = datetime.now(timezone.utc)
|
||||
out: list[dict[str, object]] = []
|
||||
for item in items:
|
||||
raw_deadline = item.get("sla_deadline_at") or item.get("case_deadline_at")
|
||||
deadline_at = parse_datetime_safe(raw_deadline)
|
||||
is_overdue = bool(deadline_at and deadline_at <= now)
|
||||
ok = True
|
||||
for op, expected in overdue_filters:
|
||||
if op == "=":
|
||||
ok = ok and (is_overdue == expected)
|
||||
elif op == "!=":
|
||||
ok = ok and (is_overdue != expected)
|
||||
if not ok:
|
||||
break
|
||||
if ok:
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def sort_kanban_items(items: list[dict[str, object]], sort_mode: str) -> list[dict[str, object]]:
|
||||
mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest"
|
||||
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
if mode == "lawyer":
|
||||
return sorted(
|
||||
items,
|
||||
key=lambda row: (
|
||||
1 if not str(row.get("assigned_lawyer_name") or "").strip() else 0,
|
||||
str(row.get("assigned_lawyer_name") or "").lower(),
|
||||
-int((parse_datetime_safe(row.get("created_at")) or epoch).timestamp()),
|
||||
),
|
||||
)
|
||||
|
||||
if mode == "deadline":
|
||||
far_future = datetime(9999, 12, 31, tzinfo=timezone.utc)
|
||||
return sorted(
|
||||
items,
|
||||
key=lambda row: (
|
||||
parse_datetime_safe(row.get("sla_deadline_at") or row.get("case_deadline_at")) or far_future,
|
||||
-int((parse_datetime_safe(row.get("created_at")) or epoch).timestamp()),
|
||||
),
|
||||
)
|
||||
|
||||
return sorted(
|
||||
items,
|
||||
key=lambda row: parse_datetime_safe(row.get("created_at")) or epoch,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def get_requests_kanban_service(
|
||||
db: Session,
|
||||
admin: dict,
|
||||
*,
|
||||
limit: int,
|
||||
filters: str | None,
|
||||
sort_mode: str,
|
||||
) -> dict[str, Any]:
|
||||
role = str(admin.get("role") or "").upper()
|
||||
actor = str(admin.get("sub") or "").strip()
|
||||
|
||||
base_query = db.query(Request)
|
||||
if role == "LAWYER":
|
||||
if not actor:
|
||||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||
base_query = base_query.filter(
|
||||
or_(
|
||||
Request.assigned_lawyer_id == actor,
|
||||
Request.assigned_lawyer_id.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest"
|
||||
query_filters, overdue_filters = parse_kanban_filters_or_400(filters)
|
||||
if query_filters:
|
||||
base_query = apply_universal_query(
|
||||
base_query,
|
||||
Request,
|
||||
UniversalQuery(
|
||||
filters=query_filters,
|
||||
sort=[],
|
||||
page=Page(limit=limit, offset=0),
|
||||
),
|
||||
)
|
||||
|
||||
request_rows: list[Request] = base_query.all()
|
||||
|
||||
request_id_to_row = {str(row.id): row for row in request_rows}
|
||||
request_ids = [row.id for row in request_rows]
|
||||
status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()}
|
||||
|
||||
status_meta_map: dict[str, dict[str, object]] = {}
|
||||
if status_codes:
|
||||
status_rows = (
|
||||
db.query(Status, StatusGroup)
|
||||
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
|
||||
.filter(Status.code.in_(list(status_codes)))
|
||||
.all()
|
||||
)
|
||||
status_meta_map = {
|
||||
str(status_row.code): {
|
||||
"name": str(status_row.name or status_row.code),
|
||||
"kind": str(status_row.kind or "DEFAULT"),
|
||||
"is_terminal": bool(status_row.is_terminal),
|
||||
"sort_order": int(status_row.sort_order or 0),
|
||||
"status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
|
||||
"status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
|
||||
"status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None),
|
||||
}
|
||||
for status_row, group_row in status_rows
|
||||
}
|
||||
|
||||
topic_codes = {str(row.topic_code or "").strip() for row in request_rows if str(row.topic_code or "").strip()}
|
||||
transition_rows: list[TopicStatusTransition] = []
|
||||
if topic_codes:
|
||||
transition_rows = (
|
||||
db.query(TopicStatusTransition)
|
||||
.filter(
|
||||
TopicStatusTransition.topic_code.in_(list(topic_codes)),
|
||||
TopicStatusTransition.enabled.is_(True),
|
||||
)
|
||||
.order_by(
|
||||
TopicStatusTransition.topic_code.asc(),
|
||||
TopicStatusTransition.sort_order.asc(),
|
||||
TopicStatusTransition.created_at.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
transitions_by_topic: dict[str, list[TopicStatusTransition]] = {}
|
||||
transition_lookup: dict[tuple[str, str, str], TopicStatusTransition] = {}
|
||||
first_incoming_by_topic_to: dict[tuple[str, str], TopicStatusTransition] = {}
|
||||
for transition in transition_rows:
|
||||
topic = str(transition.topic_code or "").strip()
|
||||
from_status = str(transition.from_status or "").strip()
|
||||
to_status = str(transition.to_status or "").strip()
|
||||
if not topic or not from_status or not to_status:
|
||||
continue
|
||||
transitions_by_topic.setdefault(topic, []).append(transition)
|
||||
transition_lookup[(topic, from_status, to_status)] = transition
|
||||
first_incoming_by_topic_to.setdefault((topic, to_status), transition)
|
||||
|
||||
assigned_ids = {
|
||||
str(row.assigned_lawyer_id or "").strip()
|
||||
for row in request_rows
|
||||
if str(row.assigned_lawyer_id or "").strip()
|
||||
}
|
||||
lawyer_name_map: dict[str, str] = {}
|
||||
if assigned_ids:
|
||||
valid_lawyer_ids: list[UUID] = []
|
||||
for raw in assigned_ids:
|
||||
try:
|
||||
valid_lawyer_ids.append(UUID(raw))
|
||||
except ValueError:
|
||||
continue
|
||||
if valid_lawyer_ids:
|
||||
lawyer_rows = db.query(AdminUser).filter(AdminUser.id.in_(valid_lawyer_ids)).all()
|
||||
lawyer_name_map = {
|
||||
str(row.id): str(row.name or row.email or row.id)
|
||||
for row in lawyer_rows
|
||||
}
|
||||
|
||||
history_rows: list[StatusHistory] = []
|
||||
if request_ids:
|
||||
history_rows = (
|
||||
db.query(StatusHistory)
|
||||
.filter(StatusHistory.request_id.in_(request_ids))
|
||||
.order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
current_status_changed_at: dict[str, datetime] = {}
|
||||
previous_status_by_request: dict[str, str] = {}
|
||||
for row in history_rows:
|
||||
request_id = str(row.request_id)
|
||||
request_row = request_id_to_row.get(request_id)
|
||||
if request_row is None:
|
||||
continue
|
||||
current_status = str(request_row.status_code or "").strip()
|
||||
to_status = str(row.to_status or "").strip()
|
||||
if not current_status or to_status != current_status:
|
||||
continue
|
||||
if request_id not in current_status_changed_at and row.created_at:
|
||||
current_status_changed_at[request_id] = row.created_at
|
||||
previous_status_by_request[request_id] = str(row.from_status or "").strip()
|
||||
|
||||
all_enabled_status_rows = (
|
||||
db.query(Status, StatusGroup)
|
||||
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
|
||||
.filter(Status.enabled.is_(True))
|
||||
.order_by(Status.sort_order.asc(), Status.name.asc(), Status.code.asc())
|
||||
.all()
|
||||
)
|
||||
all_enabled_statuses: list[dict[str, object]] = []
|
||||
for status_row, group_row in all_enabled_status_rows:
|
||||
code = str(status_row.code or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
meta = {
|
||||
"code": code,
|
||||
"name": str(status_row.name or code),
|
||||
"kind": str(status_row.kind or "DEFAULT"),
|
||||
"is_terminal": bool(status_row.is_terminal),
|
||||
"status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
|
||||
"status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
|
||||
"status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None),
|
||||
"sort_order": int(status_row.sort_order or 0),
|
||||
}
|
||||
status_meta_map.setdefault(code, meta)
|
||||
all_enabled_statuses.append(meta)
|
||||
|
||||
status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all()
|
||||
columns_catalog = [
|
||||
{
|
||||
"key": str(group.id),
|
||||
"label": str(group.name),
|
||||
"sort_order": int(group.sort_order or 0),
|
||||
}
|
||||
for group in status_groups_rows
|
||||
]
|
||||
columns_by_key = {row["key"]: row for row in columns_catalog}
|
||||
|
||||
items: list[dict[str, object]] = []
|
||||
group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog}
|
||||
for row in request_rows:
|
||||
request_id = str(row.id)
|
||||
status_code = str(row.status_code or "").strip()
|
||||
topic_code = str(row.topic_code or "").strip()
|
||||
status_meta = status_meta_or_default(status_meta_map, status_code)
|
||||
status_group = str(status_meta.get("status_group_id") or "").strip()
|
||||
status_group_name = str(status_meta.get("status_group_name") or "").strip()
|
||||
status_group_order = status_meta.get("status_group_order")
|
||||
if not status_group:
|
||||
fallback_key, fallback_label, fallback_order = fallback_group_for_status(status_code, status_meta)
|
||||
status_group = fallback_key
|
||||
status_group_name = fallback_label
|
||||
status_group_order = fallback_order
|
||||
if fallback_key not in columns_by_key:
|
||||
columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order}
|
||||
columns_catalog.append(columns_by_key[fallback_key])
|
||||
elif status_group not in columns_by_key:
|
||||
columns_by_key[status_group] = {
|
||||
"key": status_group,
|
||||
"label": status_group_name or status_group,
|
||||
"sort_order": int(status_group_order or 999),
|
||||
}
|
||||
columns_catalog.append(columns_by_key[status_group])
|
||||
|
||||
available_transitions = []
|
||||
topic_rules = transitions_by_topic.get(topic_code) or []
|
||||
if topic_rules:
|
||||
for rule in topic_rules:
|
||||
from_status = str(rule.from_status or "").strip()
|
||||
to_status = str(rule.to_status or "").strip()
|
||||
if from_status != status_code or not to_status:
|
||||
continue
|
||||
to_meta = status_meta_or_default(status_meta_map, to_status)
|
||||
target_group = str(to_meta.get("status_group_id") or "").strip()
|
||||
if not target_group:
|
||||
target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta)
|
||||
if target_group not in columns_by_key:
|
||||
columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order}
|
||||
columns_catalog.append(columns_by_key[target_group])
|
||||
if target_group not in group_totals:
|
||||
group_totals[target_group] = 0
|
||||
available_transitions.append(
|
||||
{
|
||||
"to_status": to_status,
|
||||
"to_status_name": str(to_meta.get("name") or to_status),
|
||||
"target_group": target_group,
|
||||
"is_terminal": bool(to_meta.get("is_terminal")),
|
||||
}
|
||||
)
|
||||
else:
|
||||
for status_def in all_enabled_statuses:
|
||||
to_status = str(status_def.get("code") or "").strip()
|
||||
if not to_status or to_status == status_code:
|
||||
continue
|
||||
to_meta = status_meta_or_default(status_meta_map, to_status)
|
||||
target_group = str(to_meta.get("status_group_id") or "").strip()
|
||||
if not target_group:
|
||||
target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta)
|
||||
if target_group not in columns_by_key:
|
||||
columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order}
|
||||
columns_catalog.append(columns_by_key[target_group])
|
||||
if target_group not in group_totals:
|
||||
group_totals[target_group] = 0
|
||||
available_transitions.append(
|
||||
{
|
||||
"to_status": to_status,
|
||||
"to_status_name": str(to_meta.get("name") or to_status),
|
||||
"target_group": target_group,
|
||||
"is_terminal": bool(to_meta.get("is_terminal")),
|
||||
}
|
||||
)
|
||||
|
||||
case_deadline = row.important_date_at or extract_case_deadline(row.extra_fields)
|
||||
entered_at = parse_datetime_safe(current_status_changed_at.get(request_id))
|
||||
if entered_at is None:
|
||||
entered_at = parse_datetime_safe(row.updated_at) or parse_datetime_safe(row.created_at)
|
||||
sla_deadline = None
|
||||
previous_status = str(previous_status_by_request.get(request_id) or "").strip()
|
||||
transition_rule = (
|
||||
transition_lookup.get((topic_code, previous_status, status_code))
|
||||
if previous_status
|
||||
else None
|
||||
)
|
||||
if transition_rule is None:
|
||||
transition_rule = first_incoming_by_topic_to.get((topic_code, status_code))
|
||||
if (
|
||||
transition_rule is not None
|
||||
and transition_rule.sla_hours is not None
|
||||
and int(transition_rule.sla_hours) > 0
|
||||
and entered_at is not None
|
||||
):
|
||||
sla_deadline = entered_at + timedelta(hours=int(transition_rule.sla_hours))
|
||||
|
||||
assigned_id = str(row.assigned_lawyer_id or "").strip() or None
|
||||
items.append(
|
||||
{
|
||||
"id": str(row.id),
|
||||
"track_number": row.track_number,
|
||||
"client_name": row.client_name,
|
||||
"client_phone": row.client_phone,
|
||||
"topic_code": row.topic_code,
|
||||
"status_code": status_code,
|
||||
"important_date_at": row.important_date_at.isoformat() if row.important_date_at else None,
|
||||
"status_name": str(status_meta.get("name") or status_code),
|
||||
"status_group": status_group,
|
||||
"status_group_name": status_group_name or None,
|
||||
"status_group_order": int(status_group_order or 0) if status_group_order is not None else None,
|
||||
"assigned_lawyer_id": assigned_id,
|
||||
"assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id),
|
||||
"description": row.description,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"lawyer_has_unread_updates": bool(row.lawyer_has_unread_updates),
|
||||
"lawyer_unread_event_type": row.lawyer_unread_event_type,
|
||||
"client_has_unread_updates": bool(row.client_has_unread_updates),
|
||||
"client_unread_event_type": row.client_unread_event_type,
|
||||
"case_deadline_at": case_deadline.isoformat() if case_deadline else None,
|
||||
"sla_deadline_at": sla_deadline.isoformat() if sla_deadline is not None else None,
|
||||
"available_transitions": available_transitions,
|
||||
}
|
||||
)
|
||||
|
||||
items = apply_overdue_filters(items, overdue_filters)
|
||||
items = sort_kanban_items(items, normalized_sort_mode)
|
||||
total = len(items)
|
||||
if total > limit:
|
||||
items = items[:limit]
|
||||
|
||||
for row in items:
|
||||
key = str(row.get("status_group") or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
group_totals[key] = int(group_totals.get(key, 0)) + 1
|
||||
|
||||
columns = []
|
||||
for item in sorted(
|
||||
columns_catalog,
|
||||
key=lambda row: (
|
||||
int(row.get("sort_order") or 0),
|
||||
str(row.get("label") or "").lower(),
|
||||
),
|
||||
):
|
||||
key = str(item.get("key") or "")
|
||||
if not key:
|
||||
continue
|
||||
columns.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": str(item.get("label") or key),
|
||||
"sort_order": int(item.get("sort_order") or 0),
|
||||
"total": int(group_totals.get(key, 0)),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"scope": role,
|
||||
"rows": items,
|
||||
"columns": columns,
|
||||
"total": total,
|
||||
"limit": int(limit),
|
||||
"sort_mode": normalized_sort_mode,
|
||||
"truncated": bool(total > len(items)),
|
||||
}
|
||||
117
app/api/admin/requests_modules/permissions.py
Normal file
117
app/api/admin/requests_modules/permissions.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.client import Client
|
||||
from app.models.request import Request
|
||||
|
||||
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
|
||||
|
||||
|
||||
def normalize_client_phone(value: object) -> str:
|
||||
text = "".join(ch for ch in str(value or "") if ch.isdigit() or ch == "+")
|
||||
if not text:
|
||||
return ""
|
||||
if text.startswith("8") and len(text) == 11:
|
||||
text = "+7" + text[1:]
|
||||
if not text.startswith("+") and text.isdigit():
|
||||
text = "+" + text
|
||||
return text
|
||||
|
||||
|
||||
def client_uuid_or_none(value: object) -> UUID | None:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return UUID(raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail='Некорректный "client_id"') from exc
|
||||
|
||||
|
||||
def client_for_request_payload_or_400(
|
||||
db: Session,
|
||||
*,
|
||||
client_id: object,
|
||||
client_name: object,
|
||||
client_phone: object,
|
||||
responsible: str,
|
||||
) -> Client:
|
||||
client_uuid = client_uuid_or_none(client_id)
|
||||
if client_uuid is not None:
|
||||
row = db.get(Client, client_uuid)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Клиент не найден")
|
||||
return row
|
||||
|
||||
normalized_phone = normalize_client_phone(client_phone)
|
||||
if not normalized_phone:
|
||||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно')
|
||||
normalized_name = str(client_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,
|
||||
)
|
||||
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 changed:
|
||||
row.responsible = responsible
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def request_uuid_or_400(request_id: str) -> UUID:
|
||||
try:
|
||||
return UUID(str(request_id))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки") from exc
|
||||
|
||||
|
||||
def active_lawyer_or_400(db: Session, lawyer_id: str) -> AdminUser:
|
||||
try:
|
||||
lawyer_uuid = UUID(str(lawyer_id))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Некорректный идентификатор юриста") from exc
|
||||
lawyer = db.get(AdminUser, lawyer_uuid)
|
||||
if not lawyer or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active):
|
||||
raise HTTPException(status_code=400, detail="Можно назначить только активного юриста")
|
||||
return lawyer
|
||||
|
||||
|
||||
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 actor or not assigned or actor != assigned:
|
||||
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
|
||||
|
||||
|
||||
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="Юрист может видеть только свои и неназначенные заявки")
|
||||
195
app/api/admin/requests_modules/router.py
Normal file
195
app/api/admin/requests_modules/router.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import require_role
|
||||
from app.db.session import get_db
|
||||
from app.schemas.admin import (
|
||||
RequestAdminCreate,
|
||||
RequestAdminPatch,
|
||||
RequestDataRequirementCreate,
|
||||
RequestDataRequirementPatch,
|
||||
RequestReassign,
|
||||
RequestServiceRequestPatch,
|
||||
RequestStatusChange,
|
||||
)
|
||||
from app.schemas.universal import UniversalQuery
|
||||
|
||||
from .data_templates import (
|
||||
create_request_data_requirement_service,
|
||||
delete_request_data_requirement_service,
|
||||
get_request_data_template_service,
|
||||
sync_request_data_template_from_topic_service,
|
||||
update_request_data_requirement_service,
|
||||
)
|
||||
from .kanban import get_requests_kanban_service
|
||||
from .service import (
|
||||
claim_request_service,
|
||||
create_request_service,
|
||||
delete_request_service,
|
||||
get_request_service,
|
||||
query_requests_service,
|
||||
reassign_request_service,
|
||||
update_request_service,
|
||||
)
|
||||
from .service_requests import (
|
||||
list_request_service_requests_service,
|
||||
mark_service_request_read_service,
|
||||
update_service_request_status_service,
|
||||
)
|
||||
from .status_flow import change_request_status_service, get_request_status_route_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/query")
|
||||
def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))):
|
||||
return query_requests_service(uq, db, admin)
|
||||
|
||||
|
||||
@router.get("/kanban")
|
||||
def get_requests_kanban(
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||
limit: int = Query(default=400, ge=1, le=1000),
|
||||
filters: str | None = Query(default=None),
|
||||
sort_mode: str = Query(default="created_newest"),
|
||||
):
|
||||
return get_requests_kanban_service(db, admin, limit=limit, filters=filters, sort_mode=sort_mode)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
||||
return create_request_service(payload, db, admin)
|
||||
|
||||
|
||||
@router.patch("/{request_id}")
|
||||
def update_request(
|
||||
request_id: str,
|
||||
payload: RequestAdminPatch,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||
):
|
||||
return update_request_service(request_id, payload, db, admin)
|
||||
|
||||
|
||||
@router.delete("/{request_id}")
|
||||
def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
||||
return delete_request_service(request_id, db, admin)
|
||||
|
||||
|
||||
@router.get("/{request_id}")
|
||||
def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))):
|
||||
return get_request_service(request_id, db, admin)
|
||||
|
||||
|
||||
@router.post("/{request_id}/status-change")
|
||||
def change_request_status(
|
||||
request_id: str,
|
||||
payload: RequestStatusChange,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||
):
|
||||
return change_request_status_service(request_id, payload, db, admin)
|
||||
|
||||
|
||||
@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", "CURATOR")),
|
||||
):
|
||||
return get_request_status_route_service(request_id, db, admin)
|
||||
|
||||
|
||||
@router.post("/{request_id}/claim")
|
||||
def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("LAWYER"))):
|
||||
return claim_request_service(request_id, db, admin)
|
||||
|
||||
|
||||
@router.post("/{request_id}/reassign")
|
||||
def reassign_request(
|
||||
request_id: str,
|
||||
payload: RequestReassign,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN")),
|
||||
):
|
||||
return reassign_request_service(request_id, payload.lawyer_id, db, admin)
|
||||
|
||||
|
||||
@router.get("/{request_id}/data-template")
|
||||
def get_request_data_template(
|
||||
request_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||
):
|
||||
return get_request_data_template_service(request_id, db, admin)
|
||||
|
||||
|
||||
@router.get("/{request_id}/service-requests")
|
||||
def list_request_service_requests(
|
||||
request_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||
):
|
||||
return list_request_service_requests_service(request_id, db, admin)
|
||||
|
||||
|
||||
@router.post("/service-requests/{service_request_id}/read")
|
||||
def mark_service_request_read(
|
||||
service_request_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||
):
|
||||
return mark_service_request_read_service(service_request_id, db, admin)
|
||||
|
||||
|
||||
@router.patch("/service-requests/{service_request_id}")
|
||||
def update_service_request_status(
|
||||
service_request_id: str,
|
||||
payload: RequestServiceRequestPatch,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "CURATOR")),
|
||||
):
|
||||
return update_service_request_status_service(service_request_id, payload, db, admin)
|
||||
|
||||
|
||||
@router.post("/{request_id}/data-template/sync")
|
||||
def sync_request_data_template_from_topic(
|
||||
request_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||
):
|
||||
return sync_request_data_template_from_topic_service(request_id, db, admin)
|
||||
|
||||
|
||||
@router.post("/{request_id}/data-template/items", status_code=201)
|
||||
def create_request_data_requirement(
|
||||
request_id: str,
|
||||
payload: RequestDataRequirementCreate,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||
):
|
||||
return create_request_data_requirement_service(request_id, payload, db, admin)
|
||||
|
||||
|
||||
@router.patch("/{request_id}/data-template/items/{item_id}")
|
||||
def update_request_data_requirement(
|
||||
request_id: str,
|
||||
item_id: str,
|
||||
payload: RequestDataRequirementPatch,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||
):
|
||||
return update_request_data_requirement_service(request_id, item_id, payload, db, admin)
|
||||
|
||||
|
||||
@router.delete("/{request_id}/data-template/items/{item_id}")
|
||||
def delete_request_data_requirement(
|
||||
request_id: str,
|
||||
item_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||
):
|
||||
return delete_request_data_requirement_service(request_id, item_id, db, admin)
|
||||
471
app/api/admin/requests_modules/service.py
Normal file
471
app/api/admin/requests_modules/service.py
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import case, func, or_, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.request import Request
|
||||
from app.models.request_service_request import RequestServiceRequest
|
||||
from app.schemas.admin import RequestAdminCreate, RequestAdminPatch
|
||||
from app.schemas.universal import UniversalQuery
|
||||
from app.services.billing_flow import apply_billing_transition_effects
|
||||
from app.services.notifications import (
|
||||
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||||
mark_admin_notifications_read,
|
||||
notify_request_event,
|
||||
)
|
||||
from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client
|
||||
from app.services.request_status import apply_status_change_effects
|
||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||
from app.services.status_flow import transition_allowed_for_topic
|
||||
from app.services.status_transition_requirements import validate_transition_requirements_or_400
|
||||
from app.services.universal_query import apply_universal_query
|
||||
|
||||
from .common import normalize_important_date_or_default
|
||||
from .permissions import (
|
||||
REQUEST_FINANCIAL_FIELDS,
|
||||
active_lawyer_or_400,
|
||||
client_for_request_payload_or_400,
|
||||
ensure_lawyer_can_manage_request_or_403,
|
||||
ensure_lawyer_can_view_request_or_403,
|
||||
request_uuid_or_400,
|
||||
)
|
||||
from .status_flow import apply_request_special_filters, split_request_special_filters
|
||||
|
||||
|
||||
def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict[str, Any]:
|
||||
base_query = db.query(Request)
|
||||
role = str(admin.get("role") or "").upper()
|
||||
actor = str(admin.get("sub") or "").strip()
|
||||
if role == "LAWYER":
|
||||
if not actor:
|
||||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||
base_query = base_query.filter(
|
||||
or_(
|
||||
Request.assigned_lawyer_id == actor,
|
||||
Request.assigned_lawyer_id.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
regular_uq, special_filters = split_request_special_filters(uq)
|
||||
base_query = apply_request_special_filters(
|
||||
base_query,
|
||||
db=db,
|
||||
role=role,
|
||||
actor_id=actor,
|
||||
special_filters=special_filters,
|
||||
)
|
||||
q = apply_universal_query(base_query, Request, regular_uq)
|
||||
total = q.count()
|
||||
rows = q.offset(uq.page.offset).limit(uq.page.limit).all()
|
||||
row_ids = [str(row.id) for row in rows if row and row.id]
|
||||
|
||||
unread_service_requests_by_request: dict[str, int] = {}
|
||||
if row_ids:
|
||||
unread_query = (
|
||||
db.query(RequestServiceRequest.request_id, func.count(RequestServiceRequest.id))
|
||||
.filter(RequestServiceRequest.request_id.in_(row_ids))
|
||||
)
|
||||
if role == "LAWYER":
|
||||
unread_query = unread_query.filter(
|
||||
RequestServiceRequest.type == "CURATOR_CONTACT",
|
||||
RequestServiceRequest.assigned_lawyer_id == actor,
|
||||
RequestServiceRequest.lawyer_unread.is_(True),
|
||||
)
|
||||
else:
|
||||
unread_query = unread_query.filter(RequestServiceRequest.admin_unread.is_(True))
|
||||
unread_rows = unread_query.group_by(RequestServiceRequest.request_id).all()
|
||||
unread_service_requests_by_request = {str(request_id): int(count or 0) for request_id, count in unread_rows if request_id}
|
||||
|
||||
return {
|
||||
"rows": [
|
||||
{
|
||||
"id": str(r.id),
|
||||
"track_number": r.track_number,
|
||||
"client_id": str(r.client_id) if r.client_id else None,
|
||||
"status_code": r.status_code,
|
||||
"client_name": r.client_name,
|
||||
"client_phone": r.client_phone,
|
||||
"topic_code": r.topic_code,
|
||||
"important_date_at": r.important_date_at.isoformat() if r.important_date_at else None,
|
||||
"effective_rate": float(r.effective_rate) if r.effective_rate is not None else None,
|
||||
"request_cost": float(r.request_cost) if r.request_cost is not None else None,
|
||||
"invoice_amount": float(r.invoice_amount) if r.invoice_amount is not None else None,
|
||||
"paid_at": r.paid_at.isoformat() if r.paid_at else None,
|
||||
"paid_by_admin_id": r.paid_by_admin_id,
|
||||
"client_has_unread_updates": r.client_has_unread_updates,
|
||||
"client_unread_event_type": r.client_unread_event_type,
|
||||
"lawyer_has_unread_updates": r.lawyer_has_unread_updates,
|
||||
"lawyer_unread_event_type": r.lawyer_unread_event_type,
|
||||
"service_requests_unread_count": int(unread_service_requests_by_request.get(str(r.id), 0)),
|
||||
"has_service_requests_unread": bool(unread_service_requests_by_request.get(str(r.id), 0)),
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
def create_request_service(payload: RequestAdminCreate, db: Session, admin: dict) -> dict[str, Any]:
|
||||
actor_role = str(admin.get("role") or "").upper()
|
||||
if actor_role == "LAWYER" and str(payload.assigned_lawyer_id or "").strip():
|
||||
raise HTTPException(status_code=403, detail="Юрист не может назначать заявку при создании")
|
||||
if actor_role == "LAWYER":
|
||||
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.model_fields_set)))
|
||||
if forbidden_fields:
|
||||
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||||
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
||||
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
|
||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||
client = client_for_request_payload_or_400(
|
||||
db,
|
||||
client_id=payload.client_id,
|
||||
client_name=payload.client_name,
|
||||
client_phone=payload.client_phone,
|
||||
responsible=responsible,
|
||||
)
|
||||
assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None
|
||||
effective_rate = payload.effective_rate
|
||||
if assigned_lawyer_id:
|
||||
assigned_lawyer = active_lawyer_or_400(db, assigned_lawyer_id)
|
||||
assigned_lawyer_id = str(assigned_lawyer.id)
|
||||
if effective_rate is None:
|
||||
effective_rate = assigned_lawyer.default_rate
|
||||
row = Request(
|
||||
track_number=track,
|
||||
client_id=client.id,
|
||||
client_name=client.full_name,
|
||||
client_phone=client.phone,
|
||||
topic_code=payload.topic_code,
|
||||
status_code=payload.status_code,
|
||||
important_date_at=payload.important_date_at,
|
||||
description=payload.description,
|
||||
extra_fields=payload.extra_fields,
|
||||
assigned_lawyer_id=assigned_lawyer_id,
|
||||
effective_rate=effective_rate,
|
||||
request_cost=payload.request_cost,
|
||||
invoice_amount=payload.invoice_amount,
|
||||
paid_at=payload.paid_at,
|
||||
paid_by_admin_id=payload.paid_by_admin_id,
|
||||
total_attachments_bytes=payload.total_attachments_bytes,
|
||||
responsible=responsible,
|
||||
)
|
||||
try:
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc
|
||||
return {"id": str(row.id), "track_number": row.track_number}
|
||||
|
||||
|
||||
def update_request_service(request_id: str, payload: RequestAdminPatch, db: Session, admin: dict) -> dict[str, Any]:
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
row = db.get(Request, request_uuid)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
ensure_lawyer_can_manage_request_or_403(admin, row)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
actor_role = str(admin.get("role") or "").upper()
|
||||
if actor_role == "LAWYER":
|
||||
if "assigned_lawyer_id" in changes:
|
||||
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
||||
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(changes.keys())))
|
||||
if forbidden_fields:
|
||||
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||||
if actor_role == "ADMIN" and "assigned_lawyer_id" in changes:
|
||||
assigned_raw = changes.get("assigned_lawyer_id")
|
||||
if assigned_raw is None or not str(assigned_raw).strip():
|
||||
changes["assigned_lawyer_id"] = None
|
||||
else:
|
||||
assigned_lawyer = active_lawyer_or_400(db, str(assigned_raw))
|
||||
changes["assigned_lawyer_id"] = str(assigned_lawyer.id)
|
||||
if row.effective_rate is None and "effective_rate" not in changes:
|
||||
changes["effective_rate"] = assigned_lawyer.default_rate
|
||||
old_status = str(row.status_code or "")
|
||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||
if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())):
|
||||
client = client_for_request_payload_or_400(
|
||||
db,
|
||||
client_id=changes.get("client_id", row.client_id),
|
||||
client_name=changes.get("client_name", row.client_name),
|
||||
client_phone=changes.get("client_phone", row.client_phone),
|
||||
responsible=responsible,
|
||||
)
|
||||
changes["client_id"] = client.id
|
||||
changes["client_name"] = client.full_name
|
||||
changes["client_phone"] = client.phone
|
||||
status_changed = "status_code" in changes and str(changes.get("status_code") or "") != old_status
|
||||
if status_changed and ("important_date_at" not in changes or changes.get("important_date_at") is None):
|
||||
changes["important_date_at"] = normalize_important_date_or_default(None)
|
||||
if status_changed:
|
||||
next_status = str(changes.get("status_code") or "").strip()
|
||||
if not transition_allowed_for_topic(
|
||||
db,
|
||||
str(row.topic_code or "").strip() or None,
|
||||
old_status,
|
||||
next_status,
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
|
||||
extra_fields_override = changes.get("extra_fields")
|
||||
validate_transition_requirements_or_400(
|
||||
db,
|
||||
row,
|
||||
old_status,
|
||||
next_status,
|
||||
extra_fields_override=extra_fields_override if isinstance(extra_fields_override, dict) else None,
|
||||
)
|
||||
for key, value in changes.items():
|
||||
setattr(row, key, value)
|
||||
if status_changed:
|
||||
next_status = str(changes.get("status_code") or "")
|
||||
important_date_at = row.important_date_at
|
||||
billing_note = apply_billing_transition_effects(
|
||||
db,
|
||||
req=row,
|
||||
from_status=old_status,
|
||||
to_status=next_status,
|
||||
admin=admin,
|
||||
responsible=responsible,
|
||||
)
|
||||
mark_unread_for_client(row, EVENT_STATUS)
|
||||
apply_status_change_effects(
|
||||
db,
|
||||
row,
|
||||
from_status=old_status,
|
||||
to_status=next_status,
|
||||
admin=admin,
|
||||
responsible=responsible,
|
||||
)
|
||||
notify_request_event(
|
||||
db,
|
||||
request=row,
|
||||
event_type=NOTIFICATION_EVENT_STATUS,
|
||||
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
||||
actor_admin_user_id=admin.get("sub"),
|
||||
body=(
|
||||
f"{old_status} -> {next_status}"
|
||||
+ (f"\nВажная дата: {important_date_at.isoformat()}" if important_date_at else "")
|
||||
+ (f"\n{billing_note}" if billing_note else "")
|
||||
),
|
||||
responsible=responsible,
|
||||
)
|
||||
try:
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc
|
||||
return {"status": "обновлено", "id": str(row.id), "track_number": row.track_number}
|
||||
|
||||
|
||||
def delete_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
row = db.get(Request, request_uuid)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
ensure_lawyer_can_manage_request_or_403(admin, row)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"status": "удалено"}
|
||||
|
||||
|
||||
def get_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||
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)
|
||||
changed = False
|
||||
if str(admin.get("role") or "").upper() == "LAWYER" and clear_unread_for_lawyer(req):
|
||||
changed = True
|
||||
db.add(req)
|
||||
read_count = mark_admin_notifications_read(
|
||||
db,
|
||||
admin_user_id=admin.get("sub"),
|
||||
request_id=req.id,
|
||||
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||
)
|
||||
if read_count:
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
db.refresh(req)
|
||||
return {
|
||||
"id": str(req.id),
|
||||
"track_number": req.track_number,
|
||||
"client_id": str(req.client_id) if req.client_id else None,
|
||||
"client_name": req.client_name,
|
||||
"client_phone": req.client_phone,
|
||||
"topic_code": req.topic_code,
|
||||
"status_code": req.status_code,
|
||||
"important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
|
||||
"description": req.description,
|
||||
"extra_fields": req.extra_fields,
|
||||
"assigned_lawyer_id": req.assigned_lawyer_id,
|
||||
"effective_rate": float(req.effective_rate) if req.effective_rate is not None else None,
|
||||
"request_cost": float(req.request_cost) if req.request_cost is not None else None,
|
||||
"invoice_amount": float(req.invoice_amount) if req.invoice_amount is not None else None,
|
||||
"paid_at": req.paid_at.isoformat() if req.paid_at else None,
|
||||
"paid_by_admin_id": req.paid_by_admin_id,
|
||||
"total_attachments_bytes": req.total_attachments_bytes,
|
||||
"client_has_unread_updates": req.client_has_unread_updates,
|
||||
"client_unread_event_type": req.client_unread_event_type,
|
||||
"lawyer_has_unread_updates": req.lawyer_has_unread_updates,
|
||||
"lawyer_unread_event_type": req.lawyer_unread_event_type,
|
||||
"created_at": req.created_at.isoformat() if req.created_at else None,
|
||||
"updated_at": req.updated_at.isoformat() if req.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
|
||||
lawyer_sub = str(admin.get("sub") or "").strip()
|
||||
if not lawyer_sub:
|
||||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||
try:
|
||||
lawyer_uuid = UUID(lawyer_sub)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=401, detail="Некорректный токен") from exc
|
||||
|
||||
lawyer = db.get(AdminUser, lawyer_uuid)
|
||||
if not lawyer or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active):
|
||||
raise HTTPException(status_code=403, detail="Доступно только активному юристу")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||
|
||||
stmt = (
|
||||
update(Request)
|
||||
.where(Request.id == request_uuid, Request.assigned_lawyer_id.is_(None))
|
||||
.values(
|
||||
assigned_lawyer_id=str(lawyer_uuid),
|
||||
effective_rate=case((Request.effective_rate.is_(None), lawyer.default_rate), else_=Request.effective_rate),
|
||||
updated_at=now,
|
||||
responsible=responsible,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
updated_rows = db.execute(stmt).rowcount or 0
|
||||
if updated_rows == 0:
|
||||
existing = db.get(Request, request_uuid)
|
||||
if existing is None:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Заявка уже назначена")
|
||||
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_admin_id=lawyer_uuid,
|
||||
entity="requests",
|
||||
entity_id=str(request_uuid),
|
||||
action="MANUAL_CLAIM",
|
||||
diff={"assigned_lawyer_id": str(lawyer_uuid)},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
row = db.get(Request, request_uuid)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
|
||||
return {
|
||||
"status": "claimed",
|
||||
"id": str(row.id),
|
||||
"track_number": row.track_number,
|
||||
"assigned_lawyer_id": row.assigned_lawyer_id,
|
||||
}
|
||||
|
||||
|
||||
def reassign_request_service(request_id: str, lawyer_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
|
||||
try:
|
||||
lawyer_uuid = UUID(str(lawyer_id))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Некорректный идентификатор юриста") from exc
|
||||
|
||||
target_lawyer = db.get(AdminUser, lawyer_uuid)
|
||||
if not target_lawyer or str(target_lawyer.role or "").upper() != "LAWYER" or not bool(target_lawyer.is_active):
|
||||
raise HTTPException(status_code=400, detail="Можно переназначить только на активного юриста")
|
||||
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
if req.assigned_lawyer_id is None:
|
||||
raise HTTPException(status_code=400, detail="Заявка не назначена")
|
||||
if str(req.assigned_lawyer_id) == str(lawyer_uuid):
|
||||
raise HTTPException(status_code=400, detail="Заявка уже назначена на выбранного юриста")
|
||||
|
||||
old_assigned = str(req.assigned_lawyer_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||
admin_actor_id = None
|
||||
try:
|
||||
admin_actor_id = UUID(str(admin.get("sub") or ""))
|
||||
except ValueError:
|
||||
admin_actor_id = None
|
||||
|
||||
stmt = (
|
||||
update(Request)
|
||||
.where(Request.id == request_uuid, Request.assigned_lawyer_id == old_assigned)
|
||||
.values(
|
||||
assigned_lawyer_id=str(lawyer_uuid),
|
||||
effective_rate=case((Request.effective_rate.is_(None), target_lawyer.default_rate), else_=Request.effective_rate),
|
||||
updated_at=now,
|
||||
responsible=responsible,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
updated_rows = db.execute(stmt).rowcount or 0
|
||||
if updated_rows == 0:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Заявка уже была переназначена")
|
||||
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_admin_id=admin_actor_id,
|
||||
entity="requests",
|
||||
entity_id=str(request_uuid),
|
||||
action="MANUAL_REASSIGN",
|
||||
diff={"from_lawyer_id": old_assigned, "to_lawyer_id": str(lawyer_uuid)},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
row = db.get(Request, request_uuid)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
|
||||
return {
|
||||
"status": "reassigned",
|
||||
"id": str(row.id),
|
||||
"track_number": row.track_number,
|
||||
"from_lawyer_id": old_assigned,
|
||||
"assigned_lawyer_id": row.assigned_lawyer_id,
|
||||
}
|
||||
186
app/api/admin/requests_modules/service_requests.py
Normal file
186
app/api/admin/requests_modules/service_requests.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.request import Request
|
||||
from app.models.request_service_request import RequestServiceRequest
|
||||
from app.schemas.admin import RequestServiceRequestPatch
|
||||
|
||||
from .permissions import ensure_lawyer_can_view_request_or_403, request_uuid_or_400
|
||||
|
||||
SERVICE_REQUEST_TYPES = {"CURATOR_CONTACT", "LAWYER_CHANGE_REQUEST"}
|
||||
SERVICE_REQUEST_STATUSES = {"NEW", "IN_PROGRESS", "RESOLVED", "REJECTED"}
|
||||
|
||||
|
||||
def _parse_service_request_uuid_or_400(service_request_id: str) -> UUID:
|
||||
try:
|
||||
return UUID(str(service_request_id))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Некорректный идентификатор запроса") from exc
|
||||
|
||||
|
||||
def _service_request_for_id_or_404(db: Session, service_request_id: str) -> RequestServiceRequest:
|
||||
row = db.get(RequestServiceRequest, _parse_service_request_uuid_or_400(service_request_id))
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Запрос не найден")
|
||||
return row
|
||||
|
||||
|
||||
def _resolve_responsible(admin: dict) -> str:
|
||||
return str(admin.get("email") or "").strip() or "Администратор системы"
|
||||
|
||||
|
||||
def _actor_id_or_none(admin: dict) -> str | None:
|
||||
raw = str(admin.get("sub") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
UUID(raw)
|
||||
return raw
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _actor_uuid_or_none(admin: dict) -> UUID | None:
|
||||
raw = str(admin.get("sub") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return UUID(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_lawyer_can_view_service_request_or_403(admin: dict, row: RequestServiceRequest) -> None:
|
||||
role = str(admin.get("role") or "").upper()
|
||||
if role != "LAWYER":
|
||||
return
|
||||
actor = str(admin.get("sub") or "").strip()
|
||||
row_type = str(row.type or "").strip().upper()
|
||||
assigned = str(row.assigned_lawyer_id or "").strip()
|
||||
if row_type != "CURATOR_CONTACT" or not actor or not assigned or assigned != actor:
|
||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||
|
||||
|
||||
def _serialize_service_request(row: RequestServiceRequest) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"request_id": str(row.request_id),
|
||||
"client_id": str(row.client_id) if row.client_id else None,
|
||||
"assigned_lawyer_id": str(row.assigned_lawyer_id) if row.assigned_lawyer_id else None,
|
||||
"resolved_by_admin_id": str(row.resolved_by_admin_id) if row.resolved_by_admin_id else None,
|
||||
"type": str(row.type or ""),
|
||||
"status": str(row.status or "NEW"),
|
||||
"body": str(row.body or ""),
|
||||
"created_by_client": bool(row.created_by_client),
|
||||
"admin_unread": bool(row.admin_unread),
|
||||
"lawyer_unread": bool(row.lawyer_unread),
|
||||
"admin_read_at": row.admin_read_at.isoformat() if row.admin_read_at else None,
|
||||
"lawyer_read_at": row.lawyer_read_at.isoformat() if row.lawyer_read_at else None,
|
||||
"resolved_at": row.resolved_at.isoformat() if row.resolved_at else None,
|
||||
"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 list_request_service_requests_service(request_id: str, db: Session, admin: dict) -> dict:
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
|
||||
role = str(admin.get("role") or "").upper()
|
||||
query = db.query(RequestServiceRequest).filter(RequestServiceRequest.request_id == str(req.id))
|
||||
if role == "LAWYER":
|
||||
actor_id = _actor_id_or_none(admin)
|
||||
if actor_id is None:
|
||||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||
query = query.filter(
|
||||
RequestServiceRequest.type == "CURATOR_CONTACT",
|
||||
RequestServiceRequest.assigned_lawyer_id == actor_id,
|
||||
)
|
||||
rows = query.order_by(RequestServiceRequest.created_at.desc(), RequestServiceRequest.id.desc()).all()
|
||||
return {"rows": [_serialize_service_request(row) for row in rows], "total": len(rows)}
|
||||
|
||||
|
||||
def mark_service_request_read_service(service_request_id: str, db: Session, admin: dict) -> dict:
|
||||
row = _service_request_for_id_or_404(db, service_request_id)
|
||||
role = str(admin.get("role") or "").upper()
|
||||
_ensure_lawyer_can_view_service_request_or_403(admin, row)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
changed = False
|
||||
responsible = _resolve_responsible(admin)
|
||||
actor_uuid = _actor_uuid_or_none(admin)
|
||||
action = None
|
||||
if role == "LAWYER":
|
||||
if row.lawyer_unread:
|
||||
row.lawyer_unread = False
|
||||
row.lawyer_read_at = now
|
||||
action = "READ_MARK_LAWYER"
|
||||
changed = True
|
||||
else:
|
||||
if row.admin_unread:
|
||||
row.admin_unread = False
|
||||
row.admin_read_at = now
|
||||
action = "READ_MARK_ADMIN"
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
row.responsible = responsible
|
||||
db.add(row)
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_admin_id=actor_uuid,
|
||||
entity="request_service_requests",
|
||||
entity_id=str(row.id),
|
||||
action=str(action or "READ_MARK"),
|
||||
diff={"status": str(row.status or "NEW")},
|
||||
responsible=responsible,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"status": "ok", "changed": int(changed), "row": _serialize_service_request(row)}
|
||||
|
||||
|
||||
def update_service_request_status_service(service_request_id: str, payload: RequestServiceRequestPatch, db: Session, admin: dict) -> dict:
|
||||
row = _service_request_for_id_or_404(db, service_request_id)
|
||||
next_status = str(payload.status or "").strip().upper()
|
||||
if next_status not in SERVICE_REQUEST_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="Некорректный статус запроса")
|
||||
|
||||
previous_status = str(row.status or "NEW")
|
||||
if next_status == previous_status:
|
||||
return {"status": "ok", "changed": 0, "row": _serialize_service_request(row)}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
responsible = _resolve_responsible(admin)
|
||||
actor_id = _actor_id_or_none(admin)
|
||||
actor_uuid = _actor_uuid_or_none(admin)
|
||||
|
||||
row.status = next_status
|
||||
if next_status in {"RESOLVED", "REJECTED"}:
|
||||
row.resolved_at = now
|
||||
row.resolved_by_admin_id = actor_id
|
||||
row.responsible = responsible
|
||||
db.add(row)
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_admin_id=actor_uuid,
|
||||
entity="request_service_requests",
|
||||
entity_id=str(row.id),
|
||||
action="STATUS_UPDATE",
|
||||
diff={"before": {"status": previous_status}, "after": {"status": next_status}},
|
||||
responsible=responsible,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"status": "ok", "changed": 1, "row": _serialize_service_request(row)}
|
||||
431
app/api/admin/requests_modules/status_flow.py
Normal file
431
app/api/admin/requests_modules/status_flow.py
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.request import Request
|
||||
from app.models.status import Status
|
||||
from app.models.status_group import StatusGroup
|
||||
from app.models.status_history import StatusHistory
|
||||
from app.models.topic_status_transition import TopicStatusTransition
|
||||
from app.schemas.admin import RequestStatusChange
|
||||
from app.schemas.universal import FilterClause, UniversalQuery
|
||||
from app.services.billing_flow import apply_billing_transition_effects
|
||||
from app.services.notifications import (
|
||||
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||||
notify_request_event,
|
||||
)
|
||||
from app.services.request_read_markers import EVENT_STATUS, mark_unread_for_client
|
||||
from app.services.request_status import apply_status_change_effects
|
||||
from app.services.status_flow import transition_allowed_for_topic
|
||||
from app.services.status_transition_requirements import validate_transition_requirements_or_400
|
||||
|
||||
from .common import normalize_important_date_or_default, parse_datetime_safe
|
||||
from .permissions import ensure_lawyer_can_manage_request_or_403, ensure_lawyer_can_view_request_or_403, request_uuid_or_400
|
||||
|
||||
|
||||
def terminal_status_codes(db: Session) -> set[str]:
|
||||
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
|
||||
codes = {str(code or "").strip() for (code,) in rows if str(code or "").strip()}
|
||||
return codes or {"RESOLVED", "CLOSED", "REJECTED"}
|
||||
|
||||
|
||||
def coerce_request_bool_filter_or_400(value: object) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
text = str(value or "").strip().lower()
|
||||
if text in {"1", "true", "yes", "y", "да"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "n", "нет"}:
|
||||
return False
|
||||
raise HTTPException(status_code=400, detail="Значение фильтра должно быть boolean")
|
||||
|
||||
|
||||
def split_request_special_filters(uq: UniversalQuery) -> tuple[UniversalQuery, list[FilterClause]]:
|
||||
filters = list(uq.filters or [])
|
||||
special: list[FilterClause] = []
|
||||
regular: list[FilterClause] = []
|
||||
for clause in filters:
|
||||
field = str(getattr(clause, "field", "") or "").strip()
|
||||
if field in {"has_unread_updates", "deadline_alert"}:
|
||||
special.append(clause)
|
||||
else:
|
||||
regular.append(clause)
|
||||
return UniversalQuery(filters=regular, sort=list(uq.sort or []), page=uq.page), special
|
||||
|
||||
|
||||
def apply_request_special_filters(
|
||||
base_query,
|
||||
*,
|
||||
db: Session,
|
||||
role: str,
|
||||
actor_id: str,
|
||||
special_filters: list[FilterClause],
|
||||
):
|
||||
if not special_filters:
|
||||
return base_query
|
||||
terminal_codes_cache: set[str] | None = None
|
||||
for clause in special_filters:
|
||||
field = str(clause.field or "").strip()
|
||||
op = str(clause.op or "").strip()
|
||||
if op not in {"=", "!="}:
|
||||
raise HTTPException(status_code=400, detail=f'Оператор "{op}" не поддерживается для фильтра "{field}"')
|
||||
expected = coerce_request_bool_filter_or_400(clause.value)
|
||||
if field == "has_unread_updates":
|
||||
if role == "LAWYER":
|
||||
expr = Request.lawyer_has_unread_updates.is_(True)
|
||||
else:
|
||||
expr = or_(
|
||||
Request.lawyer_has_unread_updates.is_(True),
|
||||
Request.client_has_unread_updates.is_(True),
|
||||
)
|
||||
elif field == "deadline_alert":
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
|
||||
if terminal_codes_cache is None:
|
||||
terminal_codes_cache = terminal_status_codes(db)
|
||||
expr = (
|
||||
Request.important_date_at.is_not(None)
|
||||
& (Request.important_date_at < next_day_start)
|
||||
& (Request.status_code.notin_(terminal_codes_cache))
|
||||
)
|
||||
if role == "LAWYER":
|
||||
expr = expr & (Request.assigned_lawyer_id == actor_id)
|
||||
else:
|
||||
continue
|
||||
base_query = base_query.filter(expr if expected else ~expr)
|
||||
return base_query
|
||||
|
||||
|
||||
def change_request_status_service(
|
||||
request_id: str,
|
||||
payload: RequestStatusChange,
|
||||
db: Session,
|
||||
admin: dict,
|
||||
) -> dict[str, Any]:
|
||||
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_manage_request_or_403(admin, req)
|
||||
|
||||
next_status = str(payload.status_code or "").strip()
|
||||
if not next_status:
|
||||
raise HTTPException(status_code=400, detail='Поле "status_code" обязательно')
|
||||
|
||||
status_row = db.query(Status).filter(Status.code == next_status, Status.enabled.is_(True)).first()
|
||||
if status_row is None:
|
||||
raise HTTPException(status_code=400, detail="Указан несуществующий или неактивный статус")
|
||||
|
||||
old_status = str(req.status_code or "").strip()
|
||||
if old_status == next_status:
|
||||
raise HTTPException(status_code=400, detail="Выберите новый статус")
|
||||
if not transition_allowed_for_topic(
|
||||
db,
|
||||
str(req.topic_code or "").strip() or None,
|
||||
old_status,
|
||||
next_status,
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
|
||||
|
||||
important_date_at = normalize_important_date_or_default(payload.important_date_at)
|
||||
comment = str(payload.comment or "").strip() or None
|
||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||
|
||||
validate_transition_requirements_or_400(db, req, old_status, next_status)
|
||||
|
||||
req.status_code = next_status
|
||||
req.important_date_at = important_date_at
|
||||
req.responsible = responsible
|
||||
|
||||
billing_note = apply_billing_transition_effects(
|
||||
db,
|
||||
req=req,
|
||||
from_status=old_status,
|
||||
to_status=next_status,
|
||||
admin=admin,
|
||||
responsible=responsible,
|
||||
)
|
||||
mark_unread_for_client(req, EVENT_STATUS)
|
||||
apply_status_change_effects(
|
||||
db,
|
||||
req,
|
||||
from_status=old_status,
|
||||
to_status=next_status,
|
||||
admin=admin,
|
||||
comment=comment,
|
||||
important_date_at=important_date_at,
|
||||
responsible=responsible,
|
||||
)
|
||||
notify_request_event(
|
||||
db,
|
||||
request=req,
|
||||
event_type=NOTIFICATION_EVENT_STATUS,
|
||||
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
||||
actor_admin_user_id=admin.get("sub"),
|
||||
body=(
|
||||
f"{old_status} -> {next_status}"
|
||||
+ f"\nВажная дата: {important_date_at.isoformat()}"
|
||||
+ (f"\n{comment}" if comment else "")
|
||||
+ (f"\n{billing_note}" if billing_note else "")
|
||||
),
|
||||
responsible=responsible,
|
||||
)
|
||||
|
||||
db.add(req)
|
||||
db.commit()
|
||||
db.refresh(req)
|
||||
return {
|
||||
"status": "ok",
|
||||
"request_id": str(req.id),
|
||||
"track_number": req.track_number,
|
||||
"from_status": old_status or None,
|
||||
"to_status": next_status,
|
||||
"important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
|
||||
}
|
||||
|
||||
|
||||
def get_request_status_route_service(
|
||||
request_id: str,
|
||||
db: Session,
|
||||
admin: dict,
|
||||
) -> dict[str, Any]:
|
||||
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()
|
||||
|
||||
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)
|
||||
statuses_map: dict[str, dict[str, Any]] = {}
|
||||
all_enabled_status_rows = (
|
||||
db.query(Status, StatusGroup)
|
||||
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
|
||||
.filter(Status.enabled.is_(True))
|
||||
.all()
|
||||
)
|
||||
for status_row, _group_row in all_enabled_status_rows:
|
||||
code = str(status_row.code or "").strip()
|
||||
if code:
|
||||
known_codes.add(code)
|
||||
if known_codes:
|
||||
status_rows = (
|
||||
db.query(Status, StatusGroup)
|
||||
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
|
||||
.filter(Status.code.in_(list(known_codes)))
|
||||
.all()
|
||||
)
|
||||
statuses_map = {
|
||||
str(status_row.code): {
|
||||
"name": str(status_row.name or status_row.code),
|
||||
"kind": str(status_row.kind or "DEFAULT"),
|
||||
"is_terminal": bool(status_row.is_terminal),
|
||||
"status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
|
||||
"status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
|
||||
}
|
||||
for status_row, group_row in status_rows
|
||||
}
|
||||
|
||||
transition_rows = (
|
||||
db.query(TopicStatusTransition)
|
||||
.filter(
|
||||
TopicStatusTransition.topic_code == topic_code,
|
||||
TopicStatusTransition.enabled.is_(True),
|
||||
)
|
||||
.order_by(TopicStatusTransition.sort_order.asc(), TopicStatusTransition.created_at.asc())
|
||||
.all()
|
||||
if topic_code
|
||||
else []
|
||||
)
|
||||
transition_sla_by_edge: dict[tuple[str, str], int] = {}
|
||||
outgoing_by_status: dict[str, list[str]] = {}
|
||||
incoming_sla_by_status: dict[str, int] = {}
|
||||
for transition in transition_rows:
|
||||
from_status = str(transition.from_status or "").strip()
|
||||
to_status = str(transition.to_status or "").strip()
|
||||
if not from_status or not to_status:
|
||||
continue
|
||||
outgoing_by_status.setdefault(from_status, []).append(to_status)
|
||||
sla_hours = int(transition.sla_hours or 0)
|
||||
if sla_hours > 0:
|
||||
transition_sla_by_edge[(from_status, to_status)] = sla_hours
|
||||
incoming_sla_by_status.setdefault(to_status, sla_hours)
|
||||
|
||||
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)
|
||||
|
||||
add_code(current_status)
|
||||
for to_status in outgoing_by_status.get(current_status, []):
|
||||
add_code(to_status)
|
||||
|
||||
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 {}
|
||||
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] = []
|
||||
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,
|
||||
"changed_at": changed_at_by_status.get(code),
|
||||
"sla_hours": (
|
||||
transition_sla_by_edge.get((ordered_codes[index - 1], code))
|
||||
if index > 0
|
||||
else None
|
||||
)
|
||||
or incoming_sla_by_status.get(code),
|
||||
"note": " • ".join(note_parts),
|
||||
}
|
||||
)
|
||||
|
||||
history_entries: list[dict[str, object]] = []
|
||||
timeline: list[dict[str, object]] = []
|
||||
for row in history_rows:
|
||||
timeline.append(
|
||||
{
|
||||
"id": str(row.id),
|
||||
"from_status": str(row.from_status or "").strip() or None,
|
||||
"to_status": str(row.to_status or "").strip() or None,
|
||||
"to_status_name": status_name(str(row.to_status or "").strip()) if str(row.to_status or "").strip() else None,
|
||||
"created_at": row.created_at,
|
||||
"important_date_at": row.important_date_at,
|
||||
"comment": row.comment,
|
||||
}
|
||||
)
|
||||
if not timeline:
|
||||
timeline.append(
|
||||
{
|
||||
"id": "current",
|
||||
"from_status": None,
|
||||
"to_status": current_status or None,
|
||||
"to_status_name": status_name(current_status) if current_status else None,
|
||||
"created_at": req.updated_at or req.created_at,
|
||||
"important_date_at": req.important_date_at,
|
||||
"comment": None,
|
||||
}
|
||||
)
|
||||
for index, item in enumerate(timeline):
|
||||
current_at = parse_datetime_safe(item.get("created_at"))
|
||||
next_at = parse_datetime_safe(timeline[index + 1].get("created_at")) if index + 1 < len(timeline) else datetime.now(timezone.utc)
|
||||
important_date_at = parse_datetime_safe(item.get("important_date_at"))
|
||||
duration_seconds = None
|
||||
if isinstance(current_at, datetime) and isinstance(next_at, datetime):
|
||||
delta = next_at - current_at
|
||||
duration_seconds = max(0, int(delta.total_seconds()))
|
||||
history_entries.append(
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"from_status": item.get("from_status"),
|
||||
"to_status": item.get("to_status"),
|
||||
"to_status_name": item.get("to_status_name"),
|
||||
"changed_at": current_at.isoformat() if isinstance(current_at, datetime) else None,
|
||||
"important_date_at": important_date_at.isoformat() if important_date_at else None,
|
||||
"comment": item.get("comment"),
|
||||
"duration_seconds": duration_seconds,
|
||||
}
|
||||
)
|
||||
|
||||
available_statuses: list[dict[str, object]] = []
|
||||
for status_row, group_row in sorted(
|
||||
all_enabled_status_rows,
|
||||
key=lambda pair: (
|
||||
int(pair[1].sort_order or 0) if pair[1] is not None else 999,
|
||||
int(pair[0].sort_order or 0),
|
||||
str(pair[0].name or pair[0].code).lower(),
|
||||
),
|
||||
):
|
||||
code = str(status_row.code or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
available_statuses.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": str(status_row.name or code),
|
||||
"kind": str(status_row.kind or "DEFAULT"),
|
||||
"is_terminal": bool(status_row.is_terminal),
|
||||
"status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
|
||||
"status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"request_id": str(req.id),
|
||||
"track_number": req.track_number,
|
||||
"topic_code": req.topic_code,
|
||||
"current_status": current_status or None,
|
||||
"current_important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
|
||||
"available_statuses": available_statuses,
|
||||
"history": list(reversed(history_entries)),
|
||||
"nodes": nodes,
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from fastapi import APIRouter
|
||||
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat, test_utils
|
||||
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat, test_utils, system
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
|
||||
|
|
@ -14,3 +14,4 @@ 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(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"])
|
||||
router.include_router(system.router, prefix="/system", tags=["AdminSystem"])
|
||||
|
|
|
|||
14
app/api/admin/system.py
Normal file
14
app/api/admin/system.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.deps import require_role
|
||||
from app.services.sms_service import sms_provider_health
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/sms-provider-health")
|
||||
def get_sms_provider_health(admin: dict = Depends(require_role("ADMIN"))):
|
||||
_ = admin
|
||||
return sms_provider_health()
|
||||
|
|
@ -14,6 +14,7 @@ from app.models.otp_session import OtpSession
|
|||
from app.models.request import Request as RequestModel
|
||||
from app.schemas.public import OtpSend, OtpVerify
|
||||
from app.services.rate_limit import get_rate_limiter
|
||||
from app.services.sms_service import SmsDeliveryError, send_otp_message
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -112,16 +113,6 @@ def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> Non
|
|||
)
|
||||
|
||||
|
||||
def _mock_sms_send(phone: str, code: str, purpose: str, track_number: str | None = None) -> dict:
|
||||
# Dev-only behavior: emit OTP in console instead of sending real SMS.
|
||||
print(f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}")
|
||||
return {
|
||||
"provider": "mock_sms",
|
||||
"status": "accepted",
|
||||
"message": "SMS provider response mocked",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/send")
|
||||
def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
||||
purpose = _normalize_purpose(payload.purpose)
|
||||
|
|
@ -160,6 +151,11 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
|||
)
|
||||
|
||||
code = _generate_code()
|
||||
try:
|
||||
sms_response = send_otp_message(phone=phone, code=code, purpose=purpose, track_number=track_number)
|
||||
except SmsDeliveryError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Не удалось отправить OTP: {exc}") from exc
|
||||
|
||||
now = _now_utc()
|
||||
expires_at = now + timedelta(minutes=OTP_TTL_MINUTES)
|
||||
|
||||
|
|
@ -183,7 +179,6 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
|||
db.commit()
|
||||
db.refresh(row)
|
||||
|
||||
sms_response = _mock_sms_send(phone, code, purpose, track_number)
|
||||
return {
|
||||
"status": "sent",
|
||||
"purpose": purpose,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ from app.models.attachment import Attachment
|
|||
from app.models.client import Client
|
||||
from app.models.invoice import Invoice
|
||||
from app.models.message import Message
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.request import Request
|
||||
from app.models.request_service_request import RequestServiceRequest
|
||||
from app.models.status_history import StatusHistory
|
||||
from app.models.topic import Topic
|
||||
from app.services.invoice_crypto import decrypt_requisites
|
||||
|
|
@ -37,6 +39,8 @@ from app.schemas.public import (
|
|||
PublicMessageRead,
|
||||
PublicRequestCreate,
|
||||
PublicRequestCreated,
|
||||
PublicServiceRequestCreate,
|
||||
PublicServiceRequestRead,
|
||||
PublicStatusHistoryRead,
|
||||
PublicTimelineEvent,
|
||||
)
|
||||
|
|
@ -50,6 +54,7 @@ INVOICE_STATUS_LABELS = {
|
|||
"PAID": "Оплачен",
|
||||
"CANCELED": "Отменен",
|
||||
}
|
||||
SERVICE_REQUEST_TYPES = {"CURATOR_CONTACT", "LAWYER_CHANGE_REQUEST"}
|
||||
|
||||
|
||||
def _normalize_phone(raw: str | None) -> str:
|
||||
|
|
@ -145,6 +150,21 @@ def _to_iso(value) -> str | None:
|
|||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
def _serialize_public_service_request(row: RequestServiceRequest) -> PublicServiceRequestRead:
|
||||
return PublicServiceRequestRead(
|
||||
id=row.id,
|
||||
request_id=row.request_id,
|
||||
client_id=row.client_id,
|
||||
type=str(row.type or ""),
|
||||
status=str(row.status or "NEW"),
|
||||
body=str(row.body or ""),
|
||||
created_by_client=bool(row.created_by_client),
|
||||
created_at=_to_iso(row.created_at),
|
||||
updated_at=_to_iso(row.updated_at),
|
||||
resolved_at=_to_iso(row.resolved_at),
|
||||
)
|
||||
|
||||
|
||||
def _public_invoice_payload(row: Invoice, track_number: str) -> dict:
|
||||
status_code = str(row.status or "").upper()
|
||||
return {
|
||||
|
|
@ -484,6 +504,81 @@ def list_timeline_by_track(
|
|||
return events
|
||||
|
||||
|
||||
@router.post("/{track_number}/service-requests", response_model=PublicServiceRequestRead, status_code=201)
|
||||
def create_service_request_by_track(
|
||||
track_number: str,
|
||||
payload: PublicServiceRequestCreate,
|
||||
db: Session = Depends(get_db),
|
||||
session: dict = Depends(get_public_session),
|
||||
):
|
||||
req = _request_for_track_or_404(db, session, track_number)
|
||||
request_type = str(payload.type or "").strip().upper()
|
||||
if request_type not in SERVICE_REQUEST_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Некорректный тип запроса")
|
||||
|
||||
body = str(payload.body or "").strip()
|
||||
if len(body) < 3:
|
||||
raise HTTPException(status_code=400, detail='Поле "body" должно содержать минимум 3 символа')
|
||||
|
||||
assigned_lawyer_value = None
|
||||
assigned_lawyer_raw = str(req.assigned_lawyer_id or "").strip()
|
||||
if assigned_lawyer_raw:
|
||||
assigned_lawyer_value = assigned_lawyer_raw
|
||||
|
||||
lawyer_unread = request_type == "CURATOR_CONTACT" and assigned_lawyer_value is not None
|
||||
row = RequestServiceRequest(
|
||||
request_id=str(req.id),
|
||||
client_id=str(req.client_id) if req.client_id else None,
|
||||
assigned_lawyer_id=assigned_lawyer_value,
|
||||
type=request_type,
|
||||
status="NEW",
|
||||
body=body,
|
||||
created_by_client=True,
|
||||
admin_unread=True,
|
||||
lawyer_unread=lawyer_unread,
|
||||
responsible="Клиент",
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_admin_id=None,
|
||||
entity="request_service_requests",
|
||||
entity_id=str(row.id),
|
||||
action="CREATE_CLIENT_REQUEST",
|
||||
diff={
|
||||
"request_id": str(req.id),
|
||||
"track_number": req.track_number,
|
||||
"type": request_type,
|
||||
"status": "NEW",
|
||||
},
|
||||
responsible="Клиент",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return _serialize_public_service_request(row)
|
||||
|
||||
|
||||
@router.get("/{track_number}/service-requests", response_model=list[PublicServiceRequestRead])
|
||||
def list_service_requests_by_track(
|
||||
track_number: str,
|
||||
db: Session = Depends(get_db),
|
||||
session: dict = Depends(get_public_session),
|
||||
):
|
||||
req = _request_for_track_or_404(db, session, track_number)
|
||||
rows = (
|
||||
db.query(RequestServiceRequest)
|
||||
.filter(
|
||||
RequestServiceRequest.request_id == str(req.id),
|
||||
RequestServiceRequest.created_by_client.is_(True),
|
||||
)
|
||||
.order_by(RequestServiceRequest.created_at.desc(), RequestServiceRequest.id.desc())
|
||||
.all()
|
||||
)
|
||||
return [_serialize_public_service_request(row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/{track_number}/notifications")
|
||||
def list_notifications_by_track(
|
||||
track_number: str,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ class Settings(BaseSettings):
|
|||
TELEGRAM_BOT_TOKEN: str = "change_me"
|
||||
TELEGRAM_CHAT_ID: str = "0"
|
||||
SMS_PROVIDER: str = "dummy"
|
||||
SMSAERO_EMAIL: str = ""
|
||||
SMSAERO_API_KEY: str = ""
|
||||
OTP_SMS_TEMPLATE: str = "Your verification code: {code}"
|
||||
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
|
||||
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
|
||||
OTP_SEND_RATE_LIMIT: int = 8
|
||||
|
|
|
|||
27
app/models/request_service_request.py
Normal file
27
app/models/request_service_request.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.session import Base
|
||||
from app.models.common import TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class RequestServiceRequest(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "request_service_requests"
|
||||
|
||||
request_id: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
client_id: Mapped[str | None] = mapped_column(String(60), nullable=True, index=True)
|
||||
assigned_lawyer_id: Mapped[str | None] = mapped_column(String(60), nullable=True, index=True)
|
||||
resolved_by_admin_id: Mapped[str | None] = mapped_column(String(60), nullable=True, index=True)
|
||||
|
||||
type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="NEW", index=True)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_by_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
admin_unread: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
lawyer_unread: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
admin_read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
lawyer_read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
|
@ -125,3 +125,7 @@ class RequestDataRequirementPatch(BaseModel):
|
|||
|
||||
class NotificationsReadAll(BaseModel):
|
||||
request_id: Optional[str] = None
|
||||
|
||||
|
||||
class RequestServiceRequestPatch(BaseModel):
|
||||
status: str
|
||||
|
|
|
|||
|
|
@ -65,3 +65,21 @@ class PublicTimelineEvent(BaseModel):
|
|||
type: Literal["status_change", "message", "attachment"]
|
||||
created_at: Optional[str] = None
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PublicServiceRequestCreate(BaseModel):
|
||||
type: Literal["CURATOR_CONTACT", "LAWYER_CHANGE_REQUEST"]
|
||||
body: str = Field(min_length=3, max_length=4000)
|
||||
|
||||
|
||||
class PublicServiceRequestRead(BaseModel):
|
||||
id: UUID
|
||||
request_id: UUID
|
||||
client_id: Optional[UUID] = None
|
||||
type: str
|
||||
status: str
|
||||
body: str
|
||||
created_by_client: bool
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
resolved_at: Optional[str] = None
|
||||
|
|
|
|||
188
app/services/sms_service.py
Normal file
188
app/services/sms_service.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class SmsDeliveryError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _module_available(module_name: str) -> bool:
|
||||
return importlib.util.find_spec(module_name) is not None
|
||||
|
||||
|
||||
def _normalize_phone_to_int(phone: str) -> int:
|
||||
digits = "".join(ch for ch in str(phone or "") if ch.isdigit())
|
||||
if not digits:
|
||||
raise SmsDeliveryError("Некорректный номер телефона")
|
||||
try:
|
||||
return int(digits)
|
||||
except ValueError as exc:
|
||||
raise SmsDeliveryError("Некорректный номер телефона") from exc
|
||||
|
||||
|
||||
def _build_otp_message(*, code: str, purpose: str, track_number: str | None) -> str:
|
||||
template = str(settings.OTP_SMS_TEMPLATE or "").strip() or "Ваш код подтверждения: {code}"
|
||||
try:
|
||||
rendered = template.format(code=code, purpose=purpose, track_number=track_number or "")
|
||||
except Exception:
|
||||
rendered = f"Ваш код подтверждения: {code}"
|
||||
return rendered
|
||||
|
||||
|
||||
def _mock_sms_send(*, phone: str, code: str, purpose: str, track_number: str | None) -> dict[str, Any]:
|
||||
print(f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}")
|
||||
return {
|
||||
"provider": "mock_sms",
|
||||
"status": "accepted",
|
||||
"message": "SMS provider response mocked",
|
||||
"sent": False,
|
||||
"mocked": True,
|
||||
}
|
||||
|
||||
|
||||
async def _send_sms_aero_async(*, phone: int, message: str) -> dict[str, Any]:
|
||||
try:
|
||||
import smsaero
|
||||
except Exception as exc: # pragma: no cover - runtime dependency branch
|
||||
raise SmsDeliveryError("Библиотека smsaero-api-async не установлена") from exc
|
||||
|
||||
email = str(settings.SMSAERO_EMAIL or "").strip()
|
||||
api_key = str(settings.SMSAERO_API_KEY or "").strip()
|
||||
if not email or not api_key:
|
||||
raise SmsDeliveryError("Не заданы SMSAERO_EMAIL и/или SMSAERO_API_KEY")
|
||||
|
||||
api = smsaero.SmsAero(email, api_key)
|
||||
try:
|
||||
result = await api.send_sms(phone, message)
|
||||
except Exception as exc: # pragma: no cover - network/runtime branch
|
||||
raise SmsDeliveryError(f"Ошибка отправки SMS через SMS Aero: {exc}") from exc
|
||||
finally:
|
||||
await api.close_session()
|
||||
return {
|
||||
"provider": "smsaero",
|
||||
"status": "accepted",
|
||||
"message": "SMS отправлено",
|
||||
"sent": True,
|
||||
"response": result,
|
||||
}
|
||||
|
||||
|
||||
def _send_sms_aero(*, phone: str, message: str) -> dict[str, Any]:
|
||||
phone_int = _normalize_phone_to_int(phone)
|
||||
return asyncio.run(_send_sms_aero_async(phone=phone_int, message=message))
|
||||
|
||||
|
||||
async def _get_sms_aero_balance_async() -> dict[str, Any]:
|
||||
try:
|
||||
import smsaero
|
||||
except Exception as exc: # pragma: no cover - runtime dependency branch
|
||||
raise SmsDeliveryError("Библиотека smsaero-api-async не установлена") from exc
|
||||
|
||||
email = str(settings.SMSAERO_EMAIL or "").strip()
|
||||
api_key = str(settings.SMSAERO_API_KEY or "").strip()
|
||||
if not email or not api_key:
|
||||
raise SmsDeliveryError("Не заданы SMSAERO_EMAIL и/или SMSAERO_API_KEY")
|
||||
|
||||
api = smsaero.SmsAero(email, api_key)
|
||||
try:
|
||||
result = await api.balance()
|
||||
except Exception as exc: # pragma: no cover - network/runtime branch
|
||||
raise SmsDeliveryError(f"Ошибка получения баланса SMS Aero: {exc}") from exc
|
||||
finally:
|
||||
await api.close_session()
|
||||
return dict(result or {})
|
||||
|
||||
|
||||
def _get_sms_aero_balance() -> tuple[float | None, dict[str, Any] | None, str | None]:
|
||||
try:
|
||||
raw = _get_sms_aero_balance_async()
|
||||
data = asyncio.run(raw)
|
||||
amount = data.get("balance")
|
||||
number = float(amount)
|
||||
return number, data, None
|
||||
except Exception as exc:
|
||||
return None, None, str(exc)
|
||||
|
||||
|
||||
def sms_provider_health() -> dict[str, Any]:
|
||||
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()
|
||||
if provider in {"", "dummy", "mock", "console"}:
|
||||
return {
|
||||
"provider": "dummy",
|
||||
"status": "ok",
|
||||
"mode": "mock",
|
||||
"can_send": True,
|
||||
"balance_available": False,
|
||||
"balance_amount": None,
|
||||
"balance_currency": "RUB",
|
||||
"checks": {"mock_mode": True},
|
||||
"issues": [],
|
||||
}
|
||||
|
||||
if provider in {"smsaero", "sms_aero"}:
|
||||
email = str(settings.SMSAERO_EMAIL or "").strip()
|
||||
api_key = str(settings.SMSAERO_API_KEY or "").strip()
|
||||
installed = _module_available("smsaero")
|
||||
checks = {
|
||||
"smsaero_installed": bool(installed),
|
||||
"email_configured": bool(email),
|
||||
"api_key_configured": bool(api_key),
|
||||
}
|
||||
issues: list[str] = []
|
||||
if not checks["smsaero_installed"]:
|
||||
issues.append("Не установлена библиотека smsaero-api-async")
|
||||
if not checks["email_configured"]:
|
||||
issues.append("Не задан SMSAERO_EMAIL")
|
||||
if not checks["api_key_configured"]:
|
||||
issues.append("Не задан SMSAERO_API_KEY")
|
||||
can_send = all(checks.values())
|
||||
balance_available = False
|
||||
balance_amount: float | None = None
|
||||
balance_raw: dict[str, Any] | None = None
|
||||
if can_send:
|
||||
amount, raw_balance, balance_error = _get_sms_aero_balance()
|
||||
if amount is None:
|
||||
issues.append(str(balance_error or "Не удалось получить баланс SMS Aero"))
|
||||
else:
|
||||
balance_available = True
|
||||
balance_amount = amount
|
||||
balance_raw = raw_balance
|
||||
return {
|
||||
"provider": "smsaero",
|
||||
"status": "ok" if can_send and balance_available else "degraded",
|
||||
"mode": "real",
|
||||
"can_send": can_send,
|
||||
"balance_available": balance_available,
|
||||
"balance_amount": balance_amount,
|
||||
"balance_currency": "RUB",
|
||||
"balance_raw": balance_raw,
|
||||
"checks": checks,
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"status": "error",
|
||||
"mode": "unknown",
|
||||
"can_send": False,
|
||||
"balance_available": False,
|
||||
"balance_amount": None,
|
||||
"balance_currency": "RUB",
|
||||
"checks": {"provider_supported": False},
|
||||
"issues": [f"Неизвестный SMS_PROVIDER: {provider}"],
|
||||
}
|
||||
|
||||
|
||||
def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]:
|
||||
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()
|
||||
if provider in {"", "dummy", "mock", "console"}:
|
||||
return _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
|
||||
if provider in {"smsaero", "sms_aero"}:
|
||||
message = _build_otp_message(code=code, purpose=purpose, track_number=track_number)
|
||||
return _send_sms_aero(phone=phone, message=message)
|
||||
raise SmsDeliveryError(f"Неизвестный SMS_PROVIDER: {provider}")
|
||||
|
|
@ -20,6 +20,7 @@ from app.models.request import Request
|
|||
from app.models.request_data_requirement import RequestDataRequirement
|
||||
from app.models.request_data_template import RequestDataTemplate
|
||||
from app.models.request_data_template_item import RequestDataTemplateItem
|
||||
from app.models.request_service_request import RequestServiceRequest
|
||||
from app.models.security_audit_log import SecurityAuditLog
|
||||
from app.models.status_history import StatusHistory
|
||||
from app.models.topic import Topic
|
||||
|
|
@ -105,6 +106,7 @@ def cleanup_test_data(db: Session, spec: CleanupSpec | None = None) -> dict[str,
|
|||
"invoices": 0,
|
||||
"notifications": 0,
|
||||
"request_data_requirements": 0,
|
||||
"request_service_requests": 0,
|
||||
"security_audit_log": 0,
|
||||
"audit_log": 0,
|
||||
"otp_sessions": 0,
|
||||
|
|
@ -119,12 +121,19 @@ def cleanup_test_data(db: Session, spec: CleanupSpec | None = None) -> dict[str,
|
|||
}
|
||||
|
||||
if request_ids:
|
||||
request_id_strs = {str(item) for item in request_ids}
|
||||
deleted_counts["notifications"] += (
|
||||
db.query(Notification).filter(Notification.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
|
||||
)
|
||||
deleted_counts["request_data_requirements"] += (
|
||||
db.query(RequestDataRequirement).filter(RequestDataRequirement.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
|
||||
)
|
||||
deleted_counts["request_service_requests"] += (
|
||||
db.query(RequestServiceRequest)
|
||||
.filter(RequestServiceRequest.request_id.in_(list(request_id_strs)))
|
||||
.delete(synchronize_session=False)
|
||||
or 0
|
||||
)
|
||||
deleted_counts["status_history"] += (
|
||||
db.query(StatusHistory).filter(StatusHistory.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
|
||||
)
|
||||
|
|
@ -144,7 +153,6 @@ def cleanup_test_data(db: Session, spec: CleanupSpec | None = None) -> dict[str,
|
|||
deleted_counts["security_audit_log"] += (
|
||||
db.query(SecurityAuditLog).filter(SecurityAuditLog.attachment_id.in_(attachment_ids)).delete(synchronize_session=False) or 0
|
||||
)
|
||||
request_id_strs = {str(item) for item in request_ids}
|
||||
deleted_counts["audit_log"] += (
|
||||
db.query(AuditLog)
|
||||
.filter(AuditLog.entity == "requests", AuditLog.entity_id.in_(list(request_id_strs)))
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { DashboardSection } from "./admin/features/dashboard/DashboardSection.js
|
|||
import { InvoicesSection } from "./admin/features/invoices/InvoicesSection.jsx";
|
||||
import { RequestsSection } from "./admin/features/requests/RequestsSection.jsx";
|
||||
import { QuotesSection } from "./admin/features/quotes/QuotesSection.jsx";
|
||||
import { ServiceRequestsSection } from "./admin/features/service-requests/ServiceRequestsSection.jsx";
|
||||
import { RequestWorkspace } from "./admin/features/requests/RequestWorkspace.jsx";
|
||||
import { AvailableTablesSection } from "./admin/features/tables/AvailableTablesSection.jsx";
|
||||
import { useAdminApi } from "./admin/hooks/useAdminApi.js";
|
||||
|
|
@ -897,6 +898,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
myUnreadTotal: 0,
|
||||
unreadForClients: 0,
|
||||
unreadForLawyers: 0,
|
||||
serviceRequestUnreadTotal: 0,
|
||||
deadlineAlertTotal: 0,
|
||||
monthRevenue: 0,
|
||||
monthExpenses: 0,
|
||||
|
|
@ -922,6 +924,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
});
|
||||
|
||||
const [statusMap, setStatusMap] = useState({});
|
||||
const [smsProviderHealth, setSmsProviderHealth] = useState(null);
|
||||
|
||||
const [recordModal, setRecordModal] = useState({
|
||||
open: false,
|
||||
|
|
@ -1169,6 +1172,19 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
{ field: "created_at", label: "Дата создания", type: "date" },
|
||||
];
|
||||
}
|
||||
if (tableKey === "serviceRequests") {
|
||||
return [
|
||||
{ field: "type", label: "Тип", type: "text" },
|
||||
{ field: "status", label: "Статус", type: "text" },
|
||||
{ field: "request_id", label: "ID заявки", type: "text" },
|
||||
{ field: "client_id", label: "ID клиента", type: "text" },
|
||||
{ field: "assigned_lawyer_id", label: "Назначенный юрист", type: "reference", options: getLawyerOptions },
|
||||
{ field: "admin_unread", label: "Непрочитано администратором", type: "boolean" },
|
||||
{ field: "lawyer_unread", label: "Непрочитано юристом", type: "boolean" },
|
||||
{ field: "resolved_at", label: "Дата обработки", type: "date" },
|
||||
{ field: "created_at", label: "Дата создания", type: "date" },
|
||||
];
|
||||
}
|
||||
if (tableKey === "invoices") {
|
||||
return [
|
||||
{ field: "invoice_number", label: "Номер счета", type: "text" },
|
||||
|
|
@ -1314,6 +1330,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
const getTableLabel = useCallback((tableKey) => {
|
||||
if (tableKey === "kanban") return "Канбан";
|
||||
if (tableKey === "requests") return "Заявки";
|
||||
if (tableKey === "serviceRequests") return "Запросы";
|
||||
if (tableKey === "invoices") return "Счета";
|
||||
if (tableKey === "quotes") return "Цитаты";
|
||||
if (tableKey === "topics") return "Темы";
|
||||
|
|
@ -1769,6 +1786,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
myUnreadTotal: Number(data.my_unread_updates || 0),
|
||||
unreadForClients: Number(data.unread_for_clients || 0),
|
||||
unreadForLawyers: Number(data.unread_for_lawyers || 0),
|
||||
serviceRequestUnreadTotal: Number(data.service_request_unread_total || 0),
|
||||
deadlineAlertTotal: Number(data.deadline_alert_total || 0),
|
||||
monthRevenue: Number(data.month_revenue || 0),
|
||||
monthExpenses: Number(data.month_expenses || 0),
|
||||
|
|
@ -1796,12 +1814,50 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
[api, metaEntity, setStatus]
|
||||
);
|
||||
|
||||
const loadSmsProviderHealth = useCallback(
|
||||
async (tokenOverride, options) => {
|
||||
const opts = options || {};
|
||||
const silent = Boolean(opts.silent);
|
||||
const currentRole = String(role || "").toUpperCase();
|
||||
const authToken = tokenOverride !== undefined ? tokenOverride : token;
|
||||
if (!authToken || currentRole !== "ADMIN") {
|
||||
setSmsProviderHealth(null);
|
||||
return null;
|
||||
}
|
||||
if (!silent) setStatus("smsProviderHealth", "Обновляем баланс SMS Aero...", "");
|
||||
try {
|
||||
const payload = await api("/api/admin/system/sms-provider-health", {}, tokenOverride);
|
||||
const enriched = { ...(payload || {}), loaded_at: new Date().toISOString() };
|
||||
setSmsProviderHealth(enriched);
|
||||
if (!silent) setStatus("smsProviderHealth", "Баланс SMS Aero обновлен", "ok");
|
||||
return enriched;
|
||||
} catch (error) {
|
||||
const fallback = {
|
||||
provider: "smsaero",
|
||||
status: "error",
|
||||
mode: "real",
|
||||
can_send: false,
|
||||
balance_available: false,
|
||||
balance_amount: null,
|
||||
balance_currency: "RUB",
|
||||
issues: [error.message],
|
||||
loaded_at: new Date().toISOString(),
|
||||
};
|
||||
setSmsProviderHealth(fallback);
|
||||
if (!silent) setStatus("smsProviderHealth", "Ошибка: " + error.message, "error");
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[api, role, setStatus, token]
|
||||
);
|
||||
|
||||
const refreshSection = useCallback(
|
||||
async (section, tokenOverride) => {
|
||||
if (!(tokenOverride !== undefined ? tokenOverride : token)) return;
|
||||
if (section === "dashboard") return loadDashboard(tokenOverride);
|
||||
if (section === "kanban") return loadKanban(tokenOverride);
|
||||
if (section === "requests") return loadTable("requests", {}, tokenOverride);
|
||||
if (section === "serviceRequests") return loadTable("serviceRequests", {}, tokenOverride);
|
||||
if (section === "invoices") return loadTable("invoices", {}, tokenOverride);
|
||||
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
|
||||
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
|
||||
|
|
@ -2504,6 +2560,55 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
await applyRequestsQuickFilterPreset([{ field: "deadline_alert", op: "=", value: true }], "Показаны заявки с горящими дедлайнами");
|
||||
}, [applyRequestsQuickFilterPreset]);
|
||||
|
||||
const applyServiceRequestsQuickFilterPreset = useCallback(
|
||||
async (filters, statusMessage) => {
|
||||
const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : [];
|
||||
resetAdminRoute();
|
||||
setActiveSection("serviceRequests");
|
||||
const currentState = tablesRef.current.serviceRequests || createTableState();
|
||||
setTableState("serviceRequests", {
|
||||
...currentState,
|
||||
filters: nextFilters,
|
||||
offset: 0,
|
||||
showAll: false,
|
||||
});
|
||||
if (statusMessage) setStatus("serviceRequests", statusMessage, "");
|
||||
await loadTable("serviceRequests", { resetOffset: true, filtersOverride: nextFilters });
|
||||
},
|
||||
[loadTable, resetAdminRoute, setStatus, setTableState, tablesRef]
|
||||
);
|
||||
|
||||
const openServiceRequestsWithUnreadAlerts = useCallback(async () => {
|
||||
if (String(role || "").toUpperCase() === "LAWYER") {
|
||||
await applyServiceRequestsQuickFilterPreset(
|
||||
[{ field: "lawyer_unread", op: "=", value: true }],
|
||||
"Показаны непрочитанные запросы клиента"
|
||||
);
|
||||
return;
|
||||
}
|
||||
await applyServiceRequestsQuickFilterPreset(
|
||||
[{ field: "admin_unread", op: "=", value: true }],
|
||||
"Показаны непрочитанные запросы клиента"
|
||||
);
|
||||
}, [applyServiceRequestsQuickFilterPreset, role]);
|
||||
|
||||
const markServiceRequestRead = useCallback(
|
||||
async (serviceRequestId) => {
|
||||
const rowId = String(serviceRequestId || "").trim();
|
||||
if (!rowId) return;
|
||||
try {
|
||||
setStatus("serviceRequests", "Отмечаем как прочитанный...", "");
|
||||
await api("/api/admin/requests/service-requests/" + encodeURIComponent(rowId) + "/read", { method: "POST" });
|
||||
await Promise.all([loadTable("serviceRequests", { resetOffset: true }), loadDashboard()]);
|
||||
await loadTable("requests", { resetOffset: true });
|
||||
setStatus("serviceRequests", "Запрос отмечен как прочитанный", "ok");
|
||||
} catch (error) {
|
||||
setStatus("serviceRequests", "Ошибка: " + error.message, "error");
|
||||
}
|
||||
},
|
||||
[api, loadDashboard, loadTable, setStatus]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem(LS_TOKEN);
|
||||
setToken("");
|
||||
|
|
@ -2524,6 +2629,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
myUnreadTotal: 0,
|
||||
unreadForClients: 0,
|
||||
unreadForLawyers: 0,
|
||||
serviceRequestUnreadTotal: 0,
|
||||
deadlineAlertTotal: 0,
|
||||
monthRevenue: 0,
|
||||
monthExpenses: 0,
|
||||
|
|
@ -2540,6 +2646,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
users: [],
|
||||
});
|
||||
setStatusMap({});
|
||||
setSmsProviderHealth(null);
|
||||
setActiveSection("dashboard");
|
||||
}, [resetKanbanState, resetRequestWorkspaceState, resetTablesState]);
|
||||
|
||||
|
|
@ -2628,6 +2735,19 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
}
|
||||
}, [isRequestWorkspaceRoute, loadRequestModalData, refreshSection, resetAdminRoute, role, routeInfo.requestId, routeInfo.section, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setSmsProviderHealth(null);
|
||||
return;
|
||||
}
|
||||
if (String(role || "").toUpperCase() !== "ADMIN") {
|
||||
setSmsProviderHealth(null);
|
||||
return;
|
||||
}
|
||||
if (activeSection !== "config" || configActiveKey !== "otp_sessions") return;
|
||||
loadSmsProviderHealth(undefined, { silent: true });
|
||||
}, [activeSection, configActiveKey, loadSmsProviderHealth, role, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dictionaryTableItems.length) {
|
||||
if (configActiveKey) setConfigActiveKey("");
|
||||
|
|
@ -2660,6 +2780,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
{ key: "dashboard", label: "Обзор" },
|
||||
{ key: "kanban", label: "Канбан" },
|
||||
{ key: "requests", label: "Заявки" },
|
||||
{ key: "serviceRequests", label: "Запросы" },
|
||||
{ key: "invoices", label: "Счета" },
|
||||
];
|
||||
}, []);
|
||||
|
|
@ -2671,6 +2792,10 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
}, [dashboardData.myUnreadTotal, dashboardData.unreadForClients, dashboardData.unreadForLawyers, role]);
|
||||
|
||||
const topbarDeadlineAlertCount = useMemo(() => Number(dashboardData.deadlineAlertTotal || 0), [dashboardData.deadlineAlertTotal]);
|
||||
const topbarServiceRequestUnreadCount = useMemo(
|
||||
() => Number(dashboardData.serviceRequestUnreadTotal || 0),
|
||||
[dashboardData.serviceRequestUnreadTotal]
|
||||
);
|
||||
|
||||
const activeFilterFields = useMemo(() => {
|
||||
if (!filterModal.tableKey) return [];
|
||||
|
|
@ -2790,6 +2915,27 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
<p className="muted">UniversalQuery, RBAC и аудит действий по ключевым сущностям системы.</p>
|
||||
</div>
|
||||
<div className="topbar-actions" aria-label="Быстрые уведомления и дедлайны">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
"icon-btn topbar-alert-btn" + (topbarServiceRequestUnreadCount > 0 ? " has-alert alert-danger" : "")
|
||||
}
|
||||
data-tooltip={
|
||||
topbarServiceRequestUnreadCount > 0
|
||||
? "Новые клиентские запросы: " + String(topbarServiceRequestUnreadCount)
|
||||
: "Новых клиентских запросов нет"
|
||||
}
|
||||
aria-label="Показать непрочитанные запросы клиента"
|
||||
onClick={openServiceRequestsWithUnreadAlerts}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
d="M4.5 4.5h15a1.5 1.5 0 0 1 1.5 1.5v9.8a1.5 1.5 0 0 1-1.5 1.5H9.1l-3.7 3.1c-.98.82-2.4.13-2.4-1.14V6a1.5 1.5 0 0 1 1.5-1.5zm1.7 4.2a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2zm5.8 0a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2zm5.8 0a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="topbar-alert-dot" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
|
|
@ -2905,6 +3051,33 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
/>
|
||||
</Section>
|
||||
|
||||
<Section active={activeSection === "serviceRequests"} id="section-service-requests">
|
||||
<ServiceRequestsSection
|
||||
role={role}
|
||||
tables={tables}
|
||||
status={getStatus("serviceRequests")}
|
||||
getFieldDef={getFieldDef}
|
||||
getFilterValuePreview={getFilterValuePreview}
|
||||
onRefresh={() => loadTable("serviceRequests", { resetOffset: true })}
|
||||
onOpenFilter={() => openFilterModal("serviceRequests")}
|
||||
onRemoveFilter={(index) => removeFilterChip("serviceRequests", index)}
|
||||
onEditFilter={(index) => openFilterEditModal("serviceRequests", index)}
|
||||
onSort={(field) => toggleTableSort("serviceRequests", field)}
|
||||
onPrev={() => loadPrevPage("serviceRequests")}
|
||||
onNext={() => loadNextPage("serviceRequests")}
|
||||
onLoadAll={() => loadAllRows("serviceRequests")}
|
||||
onOpenRequest={openRequestDetails}
|
||||
onMarkRead={markServiceRequestRead}
|
||||
onEditRecord={(row) => openEditRecordModal("serviceRequests", row)}
|
||||
onDeleteRecord={(id) => deleteRecord("serviceRequests", id)}
|
||||
FilterToolbarComponent={FilterToolbar}
|
||||
DataTableComponent={DataTable}
|
||||
TablePagerComponent={TablePager}
|
||||
StatusLineComponent={StatusLine}
|
||||
IconButtonComponent={IconButton}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section active={activeSection === "requestWorkspace"} id="section-request-workspace">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
|
|
@ -3034,6 +3207,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
|||
resolveTableConfig={resolveTableConfig}
|
||||
getStatus={getStatus}
|
||||
loadCurrentConfigTable={loadCurrentConfigTable}
|
||||
onRefreshSmsProviderHealth={() => loadSmsProviderHealth(undefined, { silent: false })}
|
||||
smsProviderHealth={smsProviderHealth}
|
||||
openCreateRecordModal={openCreateRecordModal}
|
||||
openFilterModal={openFilterModal}
|
||||
removeFilterChip={removeFilterChip}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,25 @@
|
|||
import { KNOWN_CONFIG_TABLE_KEYS, OPERATOR_LABELS, TABLE_SERVER_CONFIG } from "../../shared/constants.js";
|
||||
import { boolLabel, fmtDate, listPreview, normalizeReferenceMeta, roleLabel, statusKindLabel, statusLabel } from "../../shared/utils.js";
|
||||
|
||||
function fmtBalance(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return "-";
|
||||
return number.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " ₽";
|
||||
}
|
||||
|
||||
function smsBalanceSummary(health) {
|
||||
if (!health || typeof health !== "object") return "Баланс SMS Aero: загрузка...";
|
||||
const provider = String(health.provider || "").toLowerCase();
|
||||
if (provider !== "smsaero") {
|
||||
return "SMS провайдер: " + String(health.provider || "-") + " (баланс недоступен)";
|
||||
}
|
||||
if (health.balance_available) {
|
||||
return "Баланс SMS Aero: " + fmtBalance(health.balance_amount);
|
||||
}
|
||||
const issues = Array.isArray(health.issues) ? health.issues.filter(Boolean) : [];
|
||||
return "Баланс SMS Aero недоступен" + (issues.length ? " • " + String(issues[0]) : "");
|
||||
}
|
||||
|
||||
export function ConfigSection(props) {
|
||||
const {
|
||||
token,
|
||||
|
|
@ -22,6 +41,8 @@ export function ConfigSection(props) {
|
|||
resolveTableConfig,
|
||||
getStatus,
|
||||
loadCurrentConfigTable,
|
||||
onRefreshSmsProviderHealth,
|
||||
smsProviderHealth,
|
||||
openCreateRecordModal,
|
||||
openFilterModal,
|
||||
removeFilterChip,
|
||||
|
|
@ -55,10 +76,23 @@ export function ConfigSection(props) {
|
|||
<div>
|
||||
<h2>Справочники</h2>
|
||||
<p className="breadcrumbs">{configActiveKey ? getTableLabel(configActiveKey) : "Справочник не выбран"}</p>
|
||||
{configActiveKey === "otp_sessions" ? (
|
||||
<p className="muted">
|
||||
{smsBalanceSummary(smsProviderHealth)}
|
||||
{smsProviderHealth?.loaded_at ? " • обновлено " + fmtDate(smsProviderHealth.loaded_at) : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{configActiveKey === "otp_sessions" ? (
|
||||
<button className="btn secondary" type="button" onClick={onRefreshSmsProviderHealth}>
|
||||
Баланс
|
||||
</button>
|
||||
) : null}
|
||||
<button className="btn secondary" type="button" onClick={() => loadCurrentConfigTable(true)}>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn secondary" type="button" onClick={() => loadCurrentConfigTable(true)}>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
<div className="config-layout">
|
||||
<div className="config-panel">
|
||||
|
|
|
|||
|
|
@ -2,16 +2,27 @@ import { OPERATOR_LABELS, REQUEST_UPDATE_EVENT_LABELS, TABLE_SERVER_CONFIG } fro
|
|||
import { fmtDate, statusLabel } from "../../shared/utils.js";
|
||||
|
||||
function renderRequestUpdatesCell(row, role) {
|
||||
const hasServiceRequestUnread = Boolean(row?.has_service_requests_unread);
|
||||
const serviceRequestCount = Number(row?.service_requests_unread_count || 0);
|
||||
if (role === "LAWYER") {
|
||||
const has = Boolean(row.lawyer_has_unread_updates);
|
||||
const eventType = String(row.lawyer_unread_event_type || "").toUpperCase();
|
||||
return has ? (
|
||||
<span className="request-update-chip" title={"Есть непрочитанное обновление: " + (REQUEST_UPDATE_EVENT_LABELS[eventType] || eventType.toLowerCase())}>
|
||||
<span className="request-update-dot" />
|
||||
{REQUEST_UPDATE_EVENT_LABELS[eventType] || "обновление"}
|
||||
if (!has && !hasServiceRequestUnread) return <span className="request-update-empty">нет</span>;
|
||||
return (
|
||||
<span className="request-updates-stack">
|
||||
{has ? (
|
||||
<span className="request-update-chip" title={"Есть непрочитанное обновление: " + (REQUEST_UPDATE_EVENT_LABELS[eventType] || eventType.toLowerCase())}>
|
||||
<span className="request-update-dot" />
|
||||
{REQUEST_UPDATE_EVENT_LABELS[eventType] || "обновление"}
|
||||
</span>
|
||||
) : null}
|
||||
{hasServiceRequestUnread ? (
|
||||
<span className="request-update-chip" title={"Непрочитанные запросы клиента: " + String(serviceRequestCount)}>
|
||||
<span className="request-update-dot" />
|
||||
{"Запросы: " + String(serviceRequestCount || 1)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="request-update-empty">нет</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -20,7 +31,7 @@ function renderRequestUpdatesCell(row, role) {
|
|||
const lawyerHas = Boolean(row.lawyer_has_unread_updates);
|
||||
const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase();
|
||||
|
||||
if (!clientHas && !lawyerHas) return <span className="request-update-empty">нет</span>;
|
||||
if (!clientHas && !lawyerHas && !hasServiceRequestUnread) return <span className="request-update-empty">нет</span>;
|
||||
return (
|
||||
<span className="request-updates-stack">
|
||||
{clientHas ? (
|
||||
|
|
@ -35,6 +46,12 @@ function renderRequestUpdatesCell(row, role) {
|
|||
{"Юрист: " + (REQUEST_UPDATE_EVENT_LABELS[lawyerType] || "обновление")}
|
||||
</span>
|
||||
) : null}
|
||||
{hasServiceRequestUnread ? (
|
||||
<span className="request-update-chip" title={"Непрочитанные запросы клиента: " + String(serviceRequestCount)}>
|
||||
<span className="request-update-dot" />
|
||||
{"Запросы: " + String(serviceRequestCount || 1)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import {
|
||||
OPERATOR_LABELS,
|
||||
SERVICE_REQUEST_STATUS_LABELS,
|
||||
SERVICE_REQUEST_TYPE_LABELS,
|
||||
TABLE_SERVER_CONFIG,
|
||||
} from "../../shared/constants.js";
|
||||
import { fmtDate } from "../../shared/utils.js";
|
||||
|
||||
function serviceRequestTypeLabel(value) {
|
||||
const code = String(value || "").toUpperCase();
|
||||
return SERVICE_REQUEST_TYPE_LABELS[code] || code || "-";
|
||||
}
|
||||
|
||||
function serviceRequestStatusLabel(value) {
|
||||
const code = String(value || "").toUpperCase();
|
||||
return SERVICE_REQUEST_STATUS_LABELS[code] || code || "-";
|
||||
}
|
||||
|
||||
function unreadLabel(row, role) {
|
||||
if (String(role || "").toUpperCase() === "LAWYER") {
|
||||
return row?.lawyer_unread ? "Да" : "Нет";
|
||||
}
|
||||
return row?.admin_unread ? "Да" : "Нет";
|
||||
}
|
||||
|
||||
export function ServiceRequestsSection({
|
||||
role,
|
||||
tables,
|
||||
status,
|
||||
getStatus,
|
||||
getFieldDef,
|
||||
getFilterValuePreview,
|
||||
onRefresh,
|
||||
onOpenFilter,
|
||||
onRemoveFilter,
|
||||
onEditFilter,
|
||||
onSort,
|
||||
onPrev,
|
||||
onNext,
|
||||
onLoadAll,
|
||||
onOpenRequest,
|
||||
onMarkRead,
|
||||
onEditRecord,
|
||||
onDeleteRecord,
|
||||
FilterToolbarComponent,
|
||||
DataTableComponent,
|
||||
TablePagerComponent,
|
||||
StatusLineComponent,
|
||||
IconButtonComponent,
|
||||
}) {
|
||||
const tableState = tables?.serviceRequests || { rows: [], filters: [], sort: [] };
|
||||
const FilterToolbar = FilterToolbarComponent;
|
||||
const DataTable = DataTableComponent;
|
||||
const TablePager = TablePagerComponent;
|
||||
const StatusLine = StatusLineComponent;
|
||||
const IconButton = IconButtonComponent;
|
||||
const roleCode = String(role || "").toUpperCase();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Запросы</h2>
|
||||
<p className="muted">Запросы клиента к куратору и обращения на смену юриста.</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button className="btn secondary" type="button" onClick={onRefresh}>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FilterToolbar
|
||||
filters={tableState.filters}
|
||||
onOpen={onOpenFilter}
|
||||
onRemove={onRemoveFilter}
|
||||
onEdit={onEditFilter}
|
||||
getChipLabel={(clause) => {
|
||||
const fieldDef = getFieldDef("serviceRequests", clause.field);
|
||||
return (
|
||||
(fieldDef ? fieldDef.label : clause.field) +
|
||||
" " +
|
||||
OPERATOR_LABELS[clause.op] +
|
||||
" " +
|
||||
getFilterValuePreview("serviceRequests", clause)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
headers={[
|
||||
{ key: "type", label: "Тип", sortable: true, field: "type" },
|
||||
{ key: "status", label: "Статус", sortable: true, field: "status" },
|
||||
{ key: "body", label: "Обращение", sortable: false },
|
||||
{ key: "request_id", label: "Заявка", sortable: true, field: "request_id" },
|
||||
{ key: "unread", label: "Непрочитано", sortable: true, field: roleCode === "LAWYER" ? "lawyer_unread" : "admin_unread" },
|
||||
{ key: "created_at", label: "Создан", sortable: true, field: "created_at" },
|
||||
{ key: "actions", label: "Действия" },
|
||||
]}
|
||||
rows={tableState.rows}
|
||||
emptyColspan={7}
|
||||
onSort={onSort}
|
||||
sortClause={(tableState.sort && tableState.sort[0]) || TABLE_SERVER_CONFIG.serviceRequests.sort[0]}
|
||||
renderRow={(row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{serviceRequestTypeLabel(row.type)}</td>
|
||||
<td>{serviceRequestStatusLabel(row.status)}</td>
|
||||
<td>{row.body || "-"}</td>
|
||||
<td>
|
||||
{row.request_id ? (
|
||||
<button type="button" className="request-track-link" onClick={(event) => onOpenRequest(row.request_id, event)} title="Открыть заявку">
|
||||
<code>{row.request_id}</code>
|
||||
</button>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
<td>{unreadLabel(row, roleCode)}</td>
|
||||
<td>{fmtDate(row.created_at)}</td>
|
||||
<td>
|
||||
<div className="table-actions">
|
||||
<IconButton icon="✓" tooltip="Отметить прочитанным" onClick={() => onMarkRead(row.id)} />
|
||||
{roleCode === "ADMIN" ? (
|
||||
<>
|
||||
<IconButton icon="✎" tooltip="Редактировать запрос" onClick={() => onEditRecord(row)} />
|
||||
<IconButton icon="🗑" tooltip="Удалить запрос" onClick={() => onDeleteRecord(row.id)} tone="danger" />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
/>
|
||||
<TablePager tableState={tableState} onPrev={onPrev} onNext={onNext} onLoadAll={onLoadAll} />
|
||||
<StatusLine status={status || (typeof getStatus === "function" ? getStatus("serviceRequests") : null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceRequestsSection;
|
||||
|
|
@ -4,6 +4,7 @@ function createInitialTablesState() {
|
|||
return {
|
||||
kanban: createTableState(),
|
||||
requests: createTableState(),
|
||||
serviceRequests: createTableState(),
|
||||
invoices: createTableState(),
|
||||
quotes: createTableState(),
|
||||
topics: createTableState(),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const OPERATOR_LABELS = {
|
|||
export const ROLE_LABELS = {
|
||||
ADMIN: "Администратор",
|
||||
LAWYER: "Юрист",
|
||||
CURATOR: "Куратор",
|
||||
};
|
||||
|
||||
export const STATUS_LABELS = {
|
||||
|
|
@ -46,6 +47,18 @@ export const REQUEST_UPDATE_EVENT_LABELS = {
|
|||
STATUS: "статус",
|
||||
};
|
||||
|
||||
export const SERVICE_REQUEST_TYPE_LABELS = {
|
||||
CURATOR_CONTACT: "Запрос к куратору",
|
||||
LAWYER_CHANGE_REQUEST: "Смена юриста",
|
||||
};
|
||||
|
||||
export const SERVICE_REQUEST_STATUS_LABELS = {
|
||||
NEW: "Новый",
|
||||
IN_PROGRESS: "В работе",
|
||||
RESOLVED: "Решен",
|
||||
REJECTED: "Отклонен",
|
||||
};
|
||||
|
||||
export const KANBAN_GROUPS = [
|
||||
{ key: "NEW", label: "Новые" },
|
||||
{ key: "IN_PROGRESS", label: "В работе" },
|
||||
|
|
@ -61,6 +74,11 @@ export const TABLE_SERVER_CONFIG = {
|
|||
endpoint: "/api/admin/requests/query",
|
||||
sort: [{ field: "created_at", dir: "desc" }],
|
||||
},
|
||||
serviceRequests: {
|
||||
table: "request_service_requests",
|
||||
endpoint: "/api/admin/crud/request_service_requests/query",
|
||||
sort: [{ field: "created_at", dir: "desc" }],
|
||||
},
|
||||
invoices: {
|
||||
table: "invoices",
|
||||
endpoint: "/api/admin/invoices/query",
|
||||
|
|
@ -131,6 +149,7 @@ TABLE_MUTATION_CONFIG.invoices = {
|
|||
};
|
||||
|
||||
export const TABLE_KEY_ALIASES = {
|
||||
request_service_requests: "serviceRequests",
|
||||
form_fields: "formFields",
|
||||
status_groups: "statusGroups",
|
||||
topic_required_fields: "topicRequiredFields",
|
||||
|
|
|
|||
|
|
@ -269,7 +269,18 @@ export function buildUniversalQuery(filters, sort, limit, offset) {
|
|||
}
|
||||
|
||||
export function canAccessSection(role, section) {
|
||||
const allowed = new Set(["dashboard", "kanban", "requests", "requestWorkspace", "invoices", "meta", "quotes", "config", "availableTables"]);
|
||||
const allowed = new Set([
|
||||
"dashboard",
|
||||
"kanban",
|
||||
"requests",
|
||||
"serviceRequests",
|
||||
"requestWorkspace",
|
||||
"invoices",
|
||||
"meta",
|
||||
"quotes",
|
||||
"config",
|
||||
"availableTables",
|
||||
]);
|
||||
if (!allowed.has(section)) return false;
|
||||
if (section === "quotes" || section === "config" || section === "availableTables") return role === "ADMIN";
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -165,6 +165,13 @@ textarea {
|
|||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.service-request-actions {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
|
|
@ -347,6 +354,27 @@ textarea {
|
|||
width: min(760px, 100%);
|
||||
}
|
||||
|
||||
.service-request-modal {
|
||||
width: min(700px, 100%);
|
||||
}
|
||||
|
||||
.service-request-body {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
max-height: calc(92vh - 90px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #0f1722;
|
||||
padding: 0.85rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.service-request-form {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.data-request-body {
|
||||
width: 100%;
|
||||
min-height: 280px;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@
|
|||
<b id="cabinet-request-updated">-</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-request-actions">
|
||||
<button class="btn btn-ghost" id="cabinet-curator-request-open" type="button" disabled>Обратиться к куратору</button>
|
||||
<button class="btn btn-ghost" id="cabinet-lawyer-change-open" type="button" disabled>Запросить смену юриста</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
|
@ -76,6 +80,11 @@
|
|||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cabinet-card">
|
||||
<h2>Мои обращения</h2>
|
||||
<ul class="simple-list" id="cabinet-service-requests"></ul>
|
||||
</article>
|
||||
|
||||
<article class="cabinet-card">
|
||||
<h2>Счета и оплата</h2>
|
||||
<ul class="simple-list" id="cabinet-invoices"></ul>
|
||||
|
|
@ -117,6 +126,28 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-overlay" id="service-request-overlay" aria-hidden="true">
|
||||
<div class="preview-modal service-request-modal" role="dialog" aria-modal="true" aria-labelledby="service-request-title">
|
||||
<div class="preview-head">
|
||||
<h3 id="service-request-title">Новое обращение</h3>
|
||||
<button class="close-btn" id="service-request-close" type="button" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
<div class="preview-body service-request-body">
|
||||
<form id="service-request-form" class="service-request-form">
|
||||
<input id="service-request-type" type="hidden" value="">
|
||||
<div class="field">
|
||||
<label for="service-request-body">Сообщение</label>
|
||||
<textarea id="service-request-body" maxlength="4000" placeholder="Опишите обращение"></textarea>
|
||||
</div>
|
||||
<div class="data-request-actions">
|
||||
<button class="btn btn-ghost" id="service-request-send" type="submit">Отправить</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="status" id="service-request-status"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
const cabinetMessages = document.getElementById("cabinet-messages");
|
||||
const cabinetFiles = document.getElementById("cabinet-files");
|
||||
const cabinetServiceRequests = document.getElementById("cabinet-service-requests");
|
||||
const cabinetInvoices = document.getElementById("cabinet-invoices");
|
||||
const cabinetTimeline = document.getElementById("cabinet-timeline");
|
||||
|
||||
|
|
@ -29,12 +30,32 @@
|
|||
const dataRequestItems = document.getElementById("data-request-items");
|
||||
const dataRequestStatus = document.getElementById("data-request-status");
|
||||
const dataRequestTitle = document.getElementById("data-request-title");
|
||||
const serviceRequestOverlay = document.getElementById("service-request-overlay");
|
||||
const serviceRequestClose = document.getElementById("service-request-close");
|
||||
const serviceRequestForm = document.getElementById("service-request-form");
|
||||
const serviceRequestTitle = document.getElementById("service-request-title");
|
||||
const serviceRequestTypeInput = document.getElementById("service-request-type");
|
||||
const serviceRequestBodyInput = document.getElementById("service-request-body");
|
||||
const serviceRequestStatus = document.getElementById("service-request-status");
|
||||
const openCuratorRequestButton = document.getElementById("cabinet-curator-request-open");
|
||||
const openLawyerChangeButton = document.getElementById("cabinet-lawyer-change-open");
|
||||
let previewObjectUrl = "";
|
||||
|
||||
let activeTrack = "";
|
||||
let activeRequestId = "";
|
||||
let activeDataRequestMessageId = "";
|
||||
|
||||
const SERVICE_REQUEST_TYPE_LABELS = {
|
||||
CURATOR_CONTACT: "Запрос к куратору",
|
||||
LAWYER_CHANGE_REQUEST: "Смена юриста",
|
||||
};
|
||||
const SERVICE_REQUEST_STATUS_LABELS = {
|
||||
NEW: "Новый",
|
||||
IN_PROGRESS: "В работе",
|
||||
RESOLVED: "Решен",
|
||||
REJECTED: "Отклонен",
|
||||
};
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
try {
|
||||
|
|
@ -63,6 +84,11 @@
|
|||
setStatus(dataRequestStatus, message || "", kind || null);
|
||||
}
|
||||
|
||||
function setServiceRequestStatus(message, kind) {
|
||||
if (!serviceRequestStatus) return;
|
||||
setStatus(serviceRequestStatus, message || "", kind || null);
|
||||
}
|
||||
|
||||
async function uploadPublicRequestAttachment(file, requestId) {
|
||||
const initResponse = await fetch("/api/public/uploads/init", {
|
||||
method: "POST",
|
||||
|
|
@ -121,6 +147,8 @@
|
|||
cabinetFileInput.disabled = !enabled;
|
||||
cabinetFileUpload.disabled = !enabled;
|
||||
requestSelect.disabled = !enabled;
|
||||
if (openCuratorRequestButton) openCuratorRequestButton.disabled = !enabled;
|
||||
if (openLawyerChangeButton) openLawyerChangeButton.disabled = !enabled;
|
||||
}
|
||||
|
||||
function clearList(node, emptyMessage) {
|
||||
|
|
@ -183,6 +211,30 @@
|
|||
setDataRequestStatus("", null);
|
||||
}
|
||||
|
||||
function closeServiceRequestModal() {
|
||||
if (!serviceRequestOverlay) return;
|
||||
serviceRequestOverlay.classList.remove("open");
|
||||
serviceRequestOverlay.setAttribute("aria-hidden", "true");
|
||||
if (serviceRequestTypeInput) serviceRequestTypeInput.value = "";
|
||||
if (serviceRequestBodyInput) serviceRequestBodyInput.value = "";
|
||||
setServiceRequestStatus("", null);
|
||||
}
|
||||
|
||||
function openServiceRequestModal(type) {
|
||||
const requestType = String(type || "").trim().toUpperCase();
|
||||
if (!serviceRequestOverlay || !requestType) return;
|
||||
if (serviceRequestTypeInput) serviceRequestTypeInput.value = requestType;
|
||||
if (serviceRequestTitle) {
|
||||
serviceRequestTitle.textContent =
|
||||
requestType === "LAWYER_CHANGE_REQUEST" ? "Запрос на смену юриста" : "Обращение к куратору";
|
||||
}
|
||||
if (serviceRequestBodyInput) serviceRequestBodyInput.value = "";
|
||||
setServiceRequestStatus("", null);
|
||||
serviceRequestOverlay.classList.add("open");
|
||||
serviceRequestOverlay.setAttribute("aria-hidden", "false");
|
||||
if (serviceRequestBodyInput) serviceRequestBodyInput.focus();
|
||||
}
|
||||
|
||||
function dataRequestInputType(fieldType) {
|
||||
const type = String(fieldType || "").toLowerCase();
|
||||
if (type === "date") return "date";
|
||||
|
|
@ -525,6 +577,38 @@
|
|||
});
|
||||
}
|
||||
|
||||
function renderServiceRequests(items) {
|
||||
if (!cabinetServiceRequests) return;
|
||||
cabinetServiceRequests.innerHTML = "";
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
clearList(cabinetServiceRequests, "Обращений пока нет.");
|
||||
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 typeCode = String(item.type || "").toUpperCase();
|
||||
const statusCode = String(item.status || "").toUpperCase();
|
||||
const typeLabel = SERVICE_REQUEST_TYPE_LABELS[typeCode] || typeCode || "Запрос";
|
||||
const statusLabel = SERVICE_REQUEST_STATUS_LABELS[statusCode] || statusCode || "NEW";
|
||||
p.textContent = `${typeLabel} • ${statusLabel}`;
|
||||
li.appendChild(p);
|
||||
|
||||
if (item.body) {
|
||||
const bodyNode = document.createElement("p");
|
||||
bodyNode.textContent = String(item.body || "");
|
||||
li.appendChild(bodyNode);
|
||||
}
|
||||
cabinetServiceRequests.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderInvoices(items) {
|
||||
cabinetInvoices.innerHTML = "";
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
|
|
@ -602,25 +686,29 @@
|
|||
async function refreshCabinetData() {
|
||||
if (!activeTrack) return;
|
||||
|
||||
const [messagesRes, filesRes, invoicesRes, timelineRes] = await Promise.all([
|
||||
const [messagesRes, filesRes, serviceRequestsRes, 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) + "/service-requests"),
|
||||
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 serviceRequestsData = await parseJsonSafe(serviceRequestsRes);
|
||||
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 (!serviceRequestsRes.ok) throw new Error(apiErrorDetail(serviceRequestsData, "Не удалось загрузить обращения"));
|
||||
if (!invoicesRes.ok) throw new Error(apiErrorDetail(invoicesData, "Не удалось загрузить счета"));
|
||||
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
|
||||
|
||||
renderMessages(messagesData);
|
||||
renderFiles(filesData);
|
||||
renderServiceRequests(serviceRequestsData);
|
||||
renderInvoices(invoicesData);
|
||||
renderTimeline(timelineData);
|
||||
}
|
||||
|
|
@ -685,6 +773,7 @@
|
|||
setStatus(pageStatus, "По вашему номеру пока нет заявок.", null);
|
||||
clearList(cabinetMessages, "Сообщений пока нет.");
|
||||
clearList(cabinetFiles, "Файлы пока не загружены.");
|
||||
if (cabinetServiceRequests) clearList(cabinetServiceRequests, "Обращений пока нет.");
|
||||
clearList(cabinetInvoices, "Счета пока не выставлены.");
|
||||
clearList(cabinetTimeline, "История пока пуста.");
|
||||
return;
|
||||
|
|
@ -710,6 +799,13 @@
|
|||
}
|
||||
});
|
||||
|
||||
if (openCuratorRequestButton) {
|
||||
openCuratorRequestButton.addEventListener("click", () => openServiceRequestModal("CURATOR_CONTACT"));
|
||||
}
|
||||
if (openLawyerChangeButton) {
|
||||
openLawyerChangeButton.addEventListener("click", () => openServiceRequestModal("LAWYER_CHANGE_REQUEST"));
|
||||
}
|
||||
|
||||
if (previewClose) {
|
||||
previewClose.addEventListener("click", closePreview);
|
||||
}
|
||||
|
|
@ -725,6 +821,9 @@
|
|||
if (event.key === "Escape" && dataRequestOverlay?.classList.contains("open")) {
|
||||
closeDataRequestModal();
|
||||
}
|
||||
if (event.key === "Escape" && serviceRequestOverlay?.classList.contains("open")) {
|
||||
closeServiceRequestModal();
|
||||
}
|
||||
});
|
||||
|
||||
if (dataRequestClose) {
|
||||
|
|
@ -735,6 +834,48 @@
|
|||
if (event.target === dataRequestOverlay) closeDataRequestModal();
|
||||
});
|
||||
}
|
||||
if (serviceRequestClose) {
|
||||
serviceRequestClose.addEventListener("click", closeServiceRequestModal);
|
||||
}
|
||||
if (serviceRequestOverlay) {
|
||||
serviceRequestOverlay.addEventListener("click", (event) => {
|
||||
if (event.target === serviceRequestOverlay) closeServiceRequestModal();
|
||||
});
|
||||
}
|
||||
if (serviceRequestForm) {
|
||||
serviceRequestForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeTrack) {
|
||||
setServiceRequestStatus("Сначала выберите заявку.", "error");
|
||||
return;
|
||||
}
|
||||
const requestType = String(serviceRequestTypeInput?.value || "").trim().toUpperCase();
|
||||
const body = String(serviceRequestBodyInput?.value || "").trim();
|
||||
if (!requestType) {
|
||||
setServiceRequestStatus("Выберите тип обращения.", "error");
|
||||
return;
|
||||
}
|
||||
if (body.length < 3) {
|
||||
setServiceRequestStatus('Сообщение должно содержать минимум 3 символа.', "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setServiceRequestStatus("Отправляем обращение...", null);
|
||||
const response = await fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/service-requests", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: requestType, body }),
|
||||
});
|
||||
const data = await parseJsonSafe(response);
|
||||
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить обращение"));
|
||||
await refreshCabinetData();
|
||||
setStatus(pageStatus, "Обращение отправлено.", "ok");
|
||||
closeServiceRequestModal();
|
||||
} catch (error) {
|
||||
setServiceRequestStatus(error?.message || "Не удалось отправить обращение", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (dataRequestForm) {
|
||||
dataRequestForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -848,6 +989,7 @@
|
|||
setCabinetEnabled(false);
|
||||
clearList(cabinetMessages, "Сообщений пока нет.");
|
||||
clearList(cabinetFiles, "Файлы пока не загружены.");
|
||||
if (cabinetServiceRequests) clearList(cabinetServiceRequests, "Обращений пока нет.");
|
||||
clearList(cabinetInvoices, "Счета пока не выставлены.");
|
||||
clearList(cabinetTimeline, "История пока пуста.");
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -61,15 +61,15 @@
|
|||
| P40 | сделано | Декомпозиция: подготовка сборки фронта | Подготовить модульную декомпозицию фронта: перевести entrypoint `admin.jsx` -> `admin/index.jsx`, включить `esbuild --bundle` в `frontend/Dockerfile`, зафиксировать совместимость `admin.html` и Docker Compose | Реализовано: добавлен `app/web/admin/index.jsx`, сборка переведена на `esbuild admin/index.jsx --bundle`, smoke e2e входа/навигации (`admin_entry_flow`) и сборка в контейнере проходят |
|
||||
| P41 | сделано | Декомпозиция `admin.jsx`: shared-слой | Вынести из `admin.jsx` константы/маппинги/табличные конфиги и pure-utils (`format`, `filters`, `route`, `reference`) в отдельные модули | Реализовано: добавлены `app/web/admin/shared/constants.js`, `app/web/admin/shared/utils.js`, `app/web/admin/shared/state.js`; `admin.jsx` сокращен до ~4800 строк и использует shared-импорты; e2e smoke `admin_entry_flow`, `admin_role_flow`, `kanban_role_flow` зеленые |
|
||||
| P42 | сделано | Декомпозиция `admin.jsx`: feature-слой | Разделить UI и логику на feature-модули (`kanban`, `request-workspace`, `config-dictionaries`, `invoices`, `dashboard`) + вынести кастомные hooks/services (`useAdminApi`, `useTablesState`, `useRequestWorkspace`, `useKanban`) | Корневой `App` выполняет orchestration/layout, feature-код изолирован по папкам, сценарии ADMIN/LAWYER/CLIENT не деградировали |
|
||||
| P43 | к разработке | Декомпозиция backend CRUD | Разбить `app/api/admin/crud.py` на модули: `router`, `access`, `meta`, `payloads`, `service`, `audit` без изменения API-контракта и RBAC | Эндпоинты CRUD/meta работают как раньше, покрытие тестами сохранено/расширено, файл-монолит устранен |
|
||||
| P44 | к разработке | Декомпозиция backend Requests | Разбить `app/api/admin/requests.py` на модули: `router`, `kanban`, `status_flow`, `data_templates`, `permissions`, `service` с сохранением текущего поведения | Эндпоинты заявок/канбана/маршрутов статусов проходят текущие тесты, ролевые ограничения и SLA-логика без регрессий |
|
||||
| P45 | к разработке | Декомпозиция тестового слоя | Разделить `tests/test_admin_universal_crud.py` на тематические пакеты (`tests/admin/*`) + вынести общие фикстуры/фабрики | Тесты запускаются пакетно и по подмодулям, время/диагностика прогонов улучшаются, покрытие не снижается |
|
||||
| P46 | к разработке | Финализация декомпозиции | Обновить runbook/контекст по новым путям модулей и тестов, выполнить полный регрессионный прогон (unittest + e2e) и закрыть технический долг по монолитам | `context/11_test_runbook.md` и связанные контексты актуальны, полный прогон тестов зеленый, декомпозиция завершена |
|
||||
| P47 | к разработке | Запросы клиента по заявке (модель/миграции) | Добавить отдельную таблицу клиентских обращений по заявке (рабочее имя таблицы: `request_service_requests`, чтобы не конфликтовать с `requests`): тип `enum` (`CURATOR_CONTACT`, `LAWYER_CHANGE_REQUEST`), статус обработки, текст обращения, ссылки на заявку/клиента/назначенного юриста, read/unread флаги для ADMIN/LAWYER/CURATOR, аудит | Миграция применена, таблица доступна в БД, API/модели позволяют создать оба типа запросов, read/unread и аудит фиксируются |
|
||||
| P48 | к разработке | RBAC и видимость запросов (куратор/смена юриста) | Реализовать правила видимости и доступа: запрос к куратору видят ADMIN (и будущий `CURATOR`) + назначенный юрист; запрос о смене юриста не видит назначенный юрист, видит ADMIN (и будущий `CURATOR` при включении роли); предусмотреть доступ к чату заявки для куратора и отправку сообщений от его имени | Правила видимости соблюдаются серверно, назначенный юрист не видит `LAWYER_CHANGE_REQUEST`, кураторский доступ к чату и чтение/запись работают по RBAC |
|
||||
| P49 | к разработке | Клиентский UI: запрос к куратору / смена юриста | Добавить в клиентском контуре действия: (1) запрос консультации к администратору/куратору по делу; (2) запрос о смене юриста; показывать статус обработки и связанные уведомления по заявке, не раскрывая служебные поля | Клиент может создать оба типа запросов из UI заявки, видит подтверждение и статус, запросы связываются с конкретной заявкой |
|
||||
| P50 | к разработке | Админ-панель: вкладка «Запросы» + индикатор в topbar | Добавить отдельную вкладку `Запросы` наравне с `Заявки` и `Счета`; таблица в общем стиле (фильтры/сортировка/пагинация), а также отдельную topbar-иконку (левее `!` и конверта), которая подсвечивается красным при непрочитанных запросах и открывает таблицу с фильтром по непрочитанным | Вкладка `Запросы` доступна ADMIN (и CURATOR при появлении роли), topbar-иконка показывает unread и открывает отфильтрованный список, визуально согласовано с текущими индикаторами |
|
||||
| P51 | к разработке | Тесты: запросы к куратору / смена юриста | Добавить backend + e2e покрытия: создание запросов клиентом, RBAC-изоляция по типам, подсветка заявок/иконки в админке, видимость для юриста/админа/куратора, доступ к чату от куратора | Автотесты покрывают оба типа запросов и corner cases (невидимость запроса о смене юриста назначенному юристу, unread/reset, фильтрация в таблице `Запросы`) |
|
||||
| P43 | сделано | Декомпозиция backend CRUD | Разбить `app/api/admin/crud.py` на модули: `router`, `access`, `meta`, `payloads`, `service`, `audit` без изменения API-контракта и RBAC | Реализован пакет `app/api/admin/crud_modules/*`, `app/api/admin/crud.py` оставлен как compatibility shim; CRUD/meta контракты и RBAC сохранены |
|
||||
| P44 | сделано | Декомпозиция backend Requests | Разбить `app/api/admin/requests.py` на модули: `router`, `kanban`, `status_flow`, `data_templates`, `permissions`, `service` с сохранением текущего поведения | Реализован пакет `app/api/admin/requests_modules/*` и compatibility shim `app/api/admin/requests.py`; ключевые role-scope и CRUD/claim/reassign/data-template сценарии покрыты регресс-тестами |
|
||||
| P45 | сделано | Декомпозиция тестового слоя | Разделить `tests/test_admin_universal_crud.py` на тематические пакеты (`tests/admin/*`) + вынести общие фикстуры/фабрики | Создан пакет `tests/admin/*` с общей базой `tests/admin/base.py`; сценарии запускаются по подмодулям (`test_crud_meta`, `test_lawyer_chat`, `test_status_flow_kanban`, `test_assignment_users`, `test_metrics_templates`) |
|
||||
| P46 | сделано | Финализация декомпозиции | Обновить runbook/контекст по новым путям модулей и тестов, выполнить полный регрессионный прогон (unittest + e2e) и закрыть технический долг по монолитам | `context/11_test_runbook.md` актуализирован под `tests/admin/*`; выполнены прогоны: backend `133 passed`, e2e `6 passed, 1 skipped`, сборка `admin/index.jsx` успешна |
|
||||
| P47 | сделано | Запросы клиента по заявке (модель/миграции) | Добавить отдельную таблицу клиентских обращений по заявке (рабочее имя таблицы: `request_service_requests`, чтобы не конфликтовать с `requests`): тип `enum` (`CURATOR_CONTACT`, `LAWYER_CHANGE_REQUEST`), статус обработки, текст обращения, ссылки на заявку/клиента/назначенного юриста, read/unread флаги для ADMIN/LAWYER/CURATOR, аудит | Реализованы модель/API/аудит, добавлены миграции `0025` + `0026` (нормализация типов link-полей в Postgres), таблица работает в runtime и тестах |
|
||||
| P48 | сделано | RBAC и видимость запросов (куратор/смена юриста) | Реализовать правила видимости и доступа: запрос к куратору видят ADMIN (и будущий `CURATOR`) + назначенный юрист; запрос о смене юриста не видит назначенный юрист, видит ADMIN (и будущий `CURATOR` при включении роли); предусмотреть доступ к чату заявки для куратора и отправку сообщений от его имени | Серверно обеспечена изоляция типов для LAWYER, добавлена роль `CURATOR` в relevant endpoints (`requests/chat/metrics`) и CRUD-scope |
|
||||
| P49 | сделано | Клиентский UI: запрос к куратору / смена юриста | Добавить в клиентском контуре действия: (1) запрос консультации к администратору/куратору по делу; (2) запрос о смене юриста; показывать статус обработки и связанные уведомления по заявке, не раскрывая служебные поля | В `client.html` добавлены кнопки и модалка отправки двух типов обращений, список обращений и статусов в кабинете клиента |
|
||||
| P50 | сделано | Админ-панель: вкладка «Запросы» + индикатор в topbar | Добавить отдельную вкладку `Запросы` наравне с `Заявки` и `Счета`; таблица в общем стиле (фильтры/сортировка/пагинация), а также отдельную topbar-иконку (левее `!` и конверта), которая подсвечивается красным при непрочитанных запросах и открывает таблицу с фильтром по непрочитанным | Добавлены секция `Запросы`, topbar-иконка unread, quick-filter, read action и подсветка запросов в таблице `Заявки` |
|
||||
| P51 | сделано | Тесты: запросы к куратору / смена юриста | Добавить backend + e2e покрытия: создание запросов клиентом, RBAC-изоляция по типам, подсветка заявок/иконки в админке, видимость для юриста/админа/куратора, доступ к чату от куратора | Добавлены backend тесты (`tests/admin/test_service_requests.py`, `tests/admin/test_metrics_templates.py`, `tests/test_public_requests.py`) и e2e-сценарий `e2e/tests/service_requests_flow.spec.js` |
|
||||
| P52 | сделано | Лендинг: карусель выдающихся юристов | Добавить на лендинг карусель сотрудников (выдающиеся юристы) с фотографиями; в выдачу попадают только пользователи ролей `LAWYER`/`ADMIN`, у которых заполнен `avatar_url` (фото в профиле) | На лендинге отображается карусель карточек сотрудников с фото, именем и подписью; без фото сотрудник в карусель не попадает |
|
||||
| P53 | сделано | Справочник карусели сотрудников | Добавить отдельную таблицу/справочник для управления каруселью на лендинге: ссылка на сотрудника, порядок, активность, подпись, признак закрепления (`pinned`) и CRUD в админке | Администратор может добавлять/убирать сотрудников, менять порядок, задавать подпись и `pinned`; лендинг использует этот справочник для выдачи карусели |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Runbook Проверок (Тесты и Валидация по Плану)
|
||||
|
||||
## Назначение
|
||||
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P46` и как их запускать.
|
||||
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P53` и как их запускать.
|
||||
Использовать перед переводом пункта в статус `сделано`.
|
||||
Детальная role-based матрица пользовательских сценариев (UI + corner cases): `/Users/tronosfera/Develop/Law/context/13_role_flows_test_matrix.md`.
|
||||
Приоритизированный e2e backlog (P0/P1/P2 + покрытие): `/Users/tronosfera/Develop/Law/context/14_e2e_backlog_prioritized.md`.
|
||||
|
|
@ -52,54 +52,62 @@ docker compose exec -T backend python -m app.data.manual_test_seed
|
|||
|---|---|---|---|
|
||||
| P01 | Базовый запуск сервисов и API | smoke + общие тесты | `docker compose up -d`; затем базовые команды 1-3 |
|
||||
| P02 | Таблицы и миграции | `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_migrations -v` |
|
||||
| P03 | Universal CRUD + RBAC + audit | `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests -v` |
|
||||
| P04 | Пользователи, роли, пароли | `tests/test_admin_universal_crud.py` (тесты про `admin_users`) | команда как для `P03` |
|
||||
| P03 | Universal CRUD + RBAC + audit | `tests/admin/*` | `docker compose exec -T backend python -m unittest discover -s tests/admin -p 'test_*.py' -v` |
|
||||
| P04 | Пользователи, роли, пароли | `tests/admin/*` (тесты про `admin_users`) | команда как для `P03` |
|
||||
| P05 | Базовый auto-assign | `tests/test_auto_assign.py` | `docker compose exec -T backend python -m unittest tests.test_auto_assign -v` |
|
||||
| P06 | Админка `admin.jsx` + базовый UI контур | сборка admin фронта + CRUD/API тесты | базовая команда 4 + тесты `P03` |
|
||||
| P07 | Доп. темы юристов (`admin_user_topics`) | `tests/test_admin_universal_crud.py` | команда как для `P03` |
|
||||
| P08 | Ручной claim (без гонок) | `tests/test_admin_universal_crud.py` (claim-тесты) | команда как для `P03` |
|
||||
| P09 | ADMIN-only переназначение | `tests/test_admin_universal_crud.py` (reassign-тесты) | команда как для `P03` |
|
||||
| P07 | Доп. темы юристов (`admin_user_topics`) | `tests/admin/*` | команда как для `P03` |
|
||||
| P08 | Ручной claim (без гонок) | `tests/admin/*` (claim-тесты) | команда как для `P03` |
|
||||
| P09 | ADMIN-only переназначение | `tests/admin/*` (reassign-тесты) | команда как для `P03` |
|
||||
| P10 | Auto-assign v2 приоритетов | `tests/test_auto_assign.py` | команда как для `P05` |
|
||||
| P11 | OTP create/view + 7-day cookie + rate-limit | `tests/test_public_requests.py`, `tests/test_otp_rate_limit.py` | `docker compose exec -T backend python -m unittest tests.test_public_requests tests.test_otp_rate_limit -v` |
|
||||
| P12 | Публичный кабинет (статус/чат/файлы/таймлайн) | `tests/test_public_cabinet.py` | `docker compose exec -T backend python -m unittest tests.test_public_cabinet -v` |
|
||||
| P13 | Read/unread маркеры | `tests/test_public_requests.py`, `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py` | запустить 3 набора: `test_public_requests`, `test_admin_universal_crud`, `test_uploads_s3` |
|
||||
| P14 | Валидация флоу статусов по темам | `tests/test_admin_universal_crud.py` (status-flow тесты) | команда как для `P03` |
|
||||
| P15 | Иммутабельность сообщений/файлов на смене статуса | `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py` | `test_admin_universal_crud` + `test_uploads_s3` |
|
||||
| P16 | Шаблоны данных (required + request template) | `tests/test_public_requests.py`, `tests/test_admin_universal_crud.py`, `tests/test_migrations.py` | запустить 3 набора + миграции |
|
||||
| P13 | Read/unread маркеры | `tests/test_public_requests.py`, `tests/admin/*`, `tests/test_uploads_s3.py` | запустить 3 набора: `tests.test_public_requests`, `tests/admin/*` (discover), `tests.test_uploads_s3` |
|
||||
| P14 | Валидация флоу статусов по темам | `tests/admin/*` (status-flow тесты) | команда как для `P03` |
|
||||
| P15 | Иммутабельность сообщений/файлов на смене статуса | `tests/admin/*`, `tests/test_uploads_s3.py` | `tests/admin/*` (discover) + `tests.test_uploads_s3` |
|
||||
| P16 | Шаблоны данных (required + request template) | `tests/test_public_requests.py`, `tests/admin/*`, `tests/test_migrations.py` | запустить 3 набора + миграции |
|
||||
| P17 | Файловый контур и лимиты | `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_uploads_s3 tests.test_worker_maintenance -v` |
|
||||
| P18 | SLA-конфиг | `tests/test_admin_universal_crud.py`, `tests/test_migrations.py` | `alembic upgrade head`; затем `python -m unittest tests.test_admin_universal_crud tests.test_migrations -v` |
|
||||
| P19 | SLA overdue/FRT расчеты | `tests/test_worker_maintenance.py`, `tests/test_admin_universal_crud.py` (metrics) | `docker compose exec -T backend python -m unittest tests.test_worker_maintenance tests.test_admin_universal_crud -v`; проверить `overdue_by_transition` |
|
||||
| P18 | SLA-конфиг | `tests/admin/*`, `tests/test_migrations.py` | `docker compose exec -T backend alembic upgrade head`; затем `python -m unittest discover -s tests/admin -p 'test_*.py' -v` и `python -m unittest tests.test_migrations -v` |
|
||||
| P19 | SLA overdue/FRT расчеты | `tests/test_worker_maintenance.py`, `tests/admin/*` (metrics) | `docker compose exec -T backend python -m unittest tests.test_worker_maintenance -v` + `tests/admin/*` (discover); проверить `overdue_by_transition` |
|
||||
| P20 | Уведомления | `tests/test_notifications.py`, а также регрессии `tests/test_public_cabinet.py`, `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_notifications tests.test_public_cabinet tests.test_uploads_s3 tests.test_worker_maintenance -v`; затем полный прогон |
|
||||
| P21 | Dashboard ADMIN/LAWYER | `tests/test_admin_universal_crud.py` (metrics/dashboard) + `tests/test_dashboard_finance.py` | `docker compose exec -T backend python -m unittest tests.test_dashboard_finance tests.test_admin_universal_crud -v`; проверить role-scope и метрики юристов: загрузка, сумма активных, вал за месяц, зарплата за месяц |
|
||||
| P21 | Dashboard ADMIN/LAWYER | `tests/admin/*` (metrics/dashboard) + `tests/test_dashboard_finance.py` | `docker compose exec -T backend python -m unittest tests.test_dashboard_finance -v` + `tests/admin/*` (discover); проверить role-scope и метрики юристов: загрузка, сумма активных, вал за месяц, зарплата за месяц |
|
||||
| P22 | Hardening/release | `tests/test_http_hardening.py` + весь regression + compile + миграции + UI build | `docker compose exec -T backend python -m unittest tests.test_http_hardening -v`; затем базовые команды 1-4 |
|
||||
| P23 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать admin фронт при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла |
|
||||
| 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->`Оплачено` (в т.ч. множественные оплаты в одной заявке) |
|
||||
| P24 | Ставки юриста и ставка заявки | `tests/test_rates.py` + интеграционные в `tests/admin/*` | `docker compose exec -T backend python -m unittest tests.test_rates -v` + `tests/admin/*` (discover); проверка что 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 -v` + `tests/admin/*` (discover); валидация автогенерации счета при billing-статусе и фиксации оплаты только при ADMIN->`Оплачено` (в т.ч. множественные оплаты в одной заявке) |
|
||||
| P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` |
|
||||
| P27 | Итоговые E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + прогон 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 |
|
||||
| P28 | Все таблицы БД в справочниках (+ `clients`, если добавляется) | `tests/admin/*`, `tests/test_migrations.py`, UI e2e admin dictionaries | миграции + `python -m unittest discover -s tests/admin -p 'test_*.py' -v` + `python -m unittest 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 entrypoint |
|
||||
| P33 | Чат в отдельном сервисе | `tests/test_public_cabinet.py`, `tests/admin/*` (chat service cases) + UI smoke (`client.js`, `admin.jsx`) | `docker compose run --rm backend python -m unittest tests.test_public_cabinet -v` + `tests/admin/*` (discover) + фронт-сборка admin entrypoint |
|
||||
| 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` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_worker_maintenance -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/admin_status_designer_flow.spec.js` |
|
||||
| P39 | Канбан заявок для LAWYER/ADMIN | `tests/test_admin_universal_crud.py` (`test_requests_kanban_returns_grouped_cards_and_role_scope`) + e2e `e2e/tests/kanban_role_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests.test_requests_kanban_returns_grouped_cards_and_role_scope -v` и `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/kanban_role_flow.spec.js`; дополнительно регресс `admin_role_flow`, `lawyer_role_flow` |
|
||||
| P38 | Конструктор маршрутов статусов (темы) | `tests/admin/*`, `tests/test_worker_maintenance.py` + e2e `e2e/tests/admin_status_designer_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_worker_maintenance -v` + `tests/admin/*` (discover) + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/admin_status_designer_flow.spec.js` |
|
||||
| P39 | Канбан заявок для LAWYER/ADMIN | `tests/admin/*` (`test_requests_kanban_returns_grouped_cards_and_role_scope`) + e2e `e2e/tests/kanban_role_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_status_flow_kanban.AdminStatusFlowKanbanTests.test_requests_kanban_returns_grouped_cards_and_role_scope -v` и `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/kanban_role_flow.spec.js`; дополнительно регресс `admin_role_flow`, `lawyer_role_flow` |
|
||||
| P40 | Подготовка модульной сборки admin фронта | `frontend/Dockerfile`, `app/web/admin/index.jsx`, smoke e2e | базовая команда 4 + `e2e/tests/admin_entry_flow.spec.js` |
|
||||
| P41 | Декомпозиция shared-слоя admin | сборка admin фронта + role e2e smoke | базовая команда 4 + `e2e/tests/admin_role_flow.spec.js`, `e2e/tests/kanban_role_flow.spec.js` |
|
||||
| P42 | Декомпозиция feature-слоя admin | сборка admin фронта + role e2e regression | базовая команда 4 + полный e2e через сервис `e2e` |
|
||||
| P43 | Декомпозиция backend CRUD | `tests/test_admin_universal_crud.py`, `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_migrations -v` |
|
||||
| P44 | Декомпозиция backend Requests | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`, e2e kanban | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_worker_maintenance -v` + `e2e/tests/kanban_role_flow.spec.js` |
|
||||
| P43 | Декомпозиция backend CRUD | `tests/admin/*`, `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest discover -s tests/admin -p 'test_*.py' -v` + `docker compose exec -T backend python -m unittest tests.test_migrations -v` |
|
||||
| P44 | Декомпозиция backend Requests | `tests/admin/*`, `tests/test_worker_maintenance.py`, e2e kanban | `docker compose exec -T backend python -m unittest discover -s tests/admin -p 'test_*.py' -v` + `docker compose exec -T backend python -m unittest tests.test_worker_maintenance -v` + `e2e/tests/kanban_role_flow.spec.js` |
|
||||
| P45 | Декомпозиция тестового слоя | пакетный запуск `tests/admin/*` + discovery | целевые команды по новым модулям + `python -m unittest discover -s tests -p 'test_*.py' -v` |
|
||||
| P46 | Финализация декомпозиции | полный backend + frontend + e2e регресс | базовые команды 1-5 |
|
||||
| P47 | Запросы клиента по заявке (модель/миграции) | `tests/test_migrations.py`, `tests/test_public_requests.py`, `tests/admin/test_service_requests.py` | `docker compose exec -T backend alembic upgrade head`; затем `docker compose exec -T backend python -m unittest tests.test_migrations tests.test_public_requests tests.admin.test_service_requests -v` |
|
||||
| P48 | RBAC/видимость запросов + CURATOR extension points | `tests/admin/test_service_requests.py` + регресс `tests/admin/*` | `docker compose exec -T backend python -m unittest tests.admin.test_service_requests -v` + `docker compose exec -T backend python -m unittest discover -s tests/admin -p 'test_*.py' -v` |
|
||||
| P49 | Клиентский UI запросов (куратор/смена юриста) | e2e `e2e/tests/service_requests_flow.spec.js`, `e2e/tests/public_client_flow.spec.js` | `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js e2e/tests/public_client_flow.spec.js` |
|
||||
| P50 | Админ UI: вкладка `Запросы` + topbar индикатор | `tests/admin/test_metrics_templates.py`, `tests/admin/test_service_requests.py`, e2e `e2e/tests/admin_role_flow.spec.js`, `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_metrics_templates tests.admin.test_service_requests -v` + Playwright прогон указанных spec |
|
||||
| P51 | Тесты контура запросов | backend: `tests/admin/test_service_requests.py`, `tests/admin/test_metrics_templates.py`, `tests/test_public_requests.py`; e2e: `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_service_requests tests.admin.test_metrics_templates tests.test_public_requests -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js` |
|
||||
| P52 | Лендинг: карусель выдающихся юристов | `tests/test_migrations.py` (таблица карусели), ручной UI smoke лендинга | `docker compose exec -T backend python -m unittest tests.test_migrations -v` + ручная проверка лендинга (карусель сотрудников с фото) |
|
||||
| P53 | Справочник карусели сотрудников | `tests/admin/*` (meta/CRUD), `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest discover -s tests/admin -p 'test_*.py' -v` + `docker compose exec -T backend python -m unittest tests.test_migrations -v` |
|
||||
|
||||
## Ролевое покрытие (PUBLIC / LAWYER / ADMIN)
|
||||
### PUBLIC (клиент)
|
||||
- Лендинг и клиентский контур через UI e2e: `e2e/tests/public_client_flow.spec.js` (создание заявки, кабинет, чат, загрузка файла).
|
||||
- Клиентские обращения по заявке (куратор/смена юриста): `e2e/tests/service_requests_flow.spec.js`.
|
||||
- OTP create/view + 7-day cookie + rate-limit: `tests/test_public_requests.py`, `tests/test_otp_rate_limit.py`.
|
||||
- Просмотр статуса/истории/чата/файлов/таймлайна по `track_number`: `tests/test_public_cabinet.py`.
|
||||
- Переписка клиент -> юрист и маркеры непрочитанного: `tests/test_public_cabinet.py`, `tests/test_notifications.py`.
|
||||
|
|
@ -110,23 +118,25 @@ docker compose exec -T backend python -m app.data.manual_test_seed
|
|||
- UI e2e: `e2e/tests/lawyer_role_flow.spec.js` (вход, claim неназначенной заявки, новая вкладка работы с заявкой, чтение обновлений, смена статуса).
|
||||
- UI e2e: `e2e/tests/request_data_file_flow.spec.js` (юрист создает `Запрос` с `file`-полем, клиент загружает файл, юрист видит заполнение запроса).
|
||||
- Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`.
|
||||
- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`.
|
||||
- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`.
|
||||
- Смена статуса и завершение только своих заявок: `tests/test_admin_universal_crud.py`.
|
||||
- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/admin/*`.
|
||||
- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/admin/*`.
|
||||
- Смена статуса и завершение только своих заявок: `tests/admin/*`.
|
||||
- Оповещения (алерты): список/прочтение и генерация по событиям: `tests/test_notifications.py`.
|
||||
- Сообщения/файлы по заявке и непрочитанные маркеры: `tests/test_notifications.py`, `tests/test_uploads_s3.py`, `tests/test_admin_universal_crud.py`.
|
||||
- Сообщения/файлы по заявке и непрочитанные маркеры: `tests/test_notifications.py`, `tests/test_uploads_s3.py`, `tests/admin/*`.
|
||||
- Счета: видимость только своих, запрет ставить `PAID`: `tests/test_invoices.py`, `tests/test_billing_flow.py`.
|
||||
- Видимость клиентских обращений: backend RBAC `tests/admin/test_service_requests.py` (LAWYER видит только `CURATOR_CONTACT`).
|
||||
|
||||
### ADMIN (администратор)
|
||||
- UI e2e: `e2e/tests/admin_role_flow.spec.js` (вход, справочники, создание пользователя/темы, создание и оплата счета).
|
||||
- UI e2e entry/redirect smoke: `e2e/tests/admin_entry_flow.spec.js` (нет CTA админки на лендинге, вход через `/admin`).
|
||||
- Bootstrap-auth: `tests/test_admin_auth.py` (автосоздание bootstrap-admin и негативные кейсы логина).
|
||||
- CRUD пользователей/юристов (пароли, роли, профильная тема, аватар): `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py`.
|
||||
- Темы и флоу статусов (включая ветвление), SLA-переходы: `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`.
|
||||
- Шаблоны обязательных/дозапрашиваемых данных: `tests/test_admin_universal_crud.py`, `tests/test_public_requests.py`.
|
||||
- CRUD пользователей/юристов (пароли, роли, профильная тема, аватар): `tests/admin/*`, `tests/test_uploads_s3.py`.
|
||||
- Темы и флоу статусов (включая ветвление), SLA-переходы: `tests/admin/*`, `tests/test_worker_maintenance.py`.
|
||||
- Шаблоны обязательных/дозапрашиваемых данных: `tests/admin/*`, `tests/test_public_requests.py`.
|
||||
- Счета и оплаты (создание, статусы, подтверждение оплаты, multiple cycles): `tests/test_invoices.py`, `tests/test_billing_flow.py`.
|
||||
- Дашборд портала и загрузка юристов + финансовые метрики: `tests/test_dashboard_finance.py`, `tests/test_admin_universal_crud.py`.
|
||||
- Дашборд портала и загрузка юристов + финансовые метрики: `tests/test_dashboard_finance.py`, `tests/admin/*`.
|
||||
- Безопасность: security-audit по S3 доступам, RBAC и шифрование реквизитов: `tests/test_security_audit.py`, `tests/test_uploads_s3.py`, `tests/test_invoices.py`.
|
||||
- Вкладка `Запросы` + unread индикатор topbar: `tests/admin/test_metrics_templates.py`, `tests/admin/test_service_requests.py`, `e2e/tests/service_requests_flow.spec.js`.
|
||||
|
||||
## Минимальный чеклист закрытия пункта
|
||||
1. Выполнить миграции (если были изменения схемы).
|
||||
|
|
@ -137,9 +147,10 @@ docker compose exec -T backend python -m app.data.manual_test_seed
|
|||
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
|
||||
|
||||
## Последние подтвержденные прогоны
|
||||
- `docker compose exec -T backend python -m unittest discover -s tests/admin -p 'test_*.py' -v` — `32 passed`.
|
||||
- `docker compose run --rm backend python -m unittest -v tests.test_admin_auth` — `3 passed`.
|
||||
- `docker compose run --rm backend python -m unittest discover -s tests -p 'test_*.py' -v` — `105 passed`.
|
||||
- `docker compose run --rm backend python -m compileall app tests alembic` — успешно.
|
||||
- `docker compose exec -T backend python -m unittest discover -s tests -p 'test_*.py' -v` — `133 passed`.
|
||||
- `docker compose exec -T backend python -m compileall app tests alembic` — успешно.
|
||||
- `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/index.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js"` — успешно (`admin.bundle.js` собран).
|
||||
- `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 e2e playwright test --config=playwright.config.js e2e/tests/admin_entry_flow.spec.js e2e/tests/admin_role_flow.spec.js` — `2 passed`.
|
||||
|
|
@ -155,4 +166,12 @@ docker compose exec -T backend python -m app.data.manual_test_seed
|
|||
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line` — `2 passed` (после переноса `openRequestDetails` и `submitRequestModalMessage` в `useRequestWorkspace`).
|
||||
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/admin_status_designer_flow.spec.js e2e/tests/kanban_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line` — `4 passed` (после выноса `useTableActions`: `loadTable` + paging/sort).
|
||||
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/admin_status_designer_flow.spec.js e2e/tests/kanban_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line` — `4 passed` (после выноса `useTableFilterActions` и `useAdminCatalogLoaders`, закрытие `P42`).
|
||||
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js` — `6 passed` (рольовые e2e + конструктор статусов + канбан: `admin_entry_flow`, `admin_role_flow`, `admin_status_designer_flow`, `kanban_role_flow`, `lawyer_role_flow`, `public_client_flow`).
|
||||
- `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/tests/admin_role_flow.spec.js e2e/tests/admin_status_designer_flow.spec.js e2e/tests/kanban_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js e2e/tests/request_data_file_flow.spec.js --reporter=line` — `4 passed, 1 skipped`.
|
||||
- `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 --reporter=line` — `6 passed, 1 skipped` (рольовые e2e + канбан + запрос данных).
|
||||
- `docker compose exec -T backend alembic upgrade head` — успешно, применена миграция `0026_srv_req_str_ids`.
|
||||
- `docker compose exec -T backend python -m unittest tests.admin.test_service_requests tests.admin.test_metrics_templates tests.test_public_requests tests.test_migrations -v` — `38 passed`.
|
||||
- `docker compose exec -T backend python -m compileall app tests alembic` — успешно.
|
||||
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend -e E2E_ADMIN_EMAIL=admin@example.com -e E2E_ADMIN_PASSWORD='admin123' e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js --reporter=line` — `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 e2e/tests/admin_role_flow.spec.js e2e/tests/service_requests_flow.spec.js --reporter=line` — `2 passed`.
|
||||
- `docker compose exec -T backend python -m unittest discover -s tests -v` — `140 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 e2e playwright test --config=playwright.config.js e2e/tests --reporter=line` — `7 passed, 1 skipped`.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ test("admin flow via UI: dictionaries + users + topics + invoices", async ({ con
|
|||
trackCleanupTrack(testInfo, trackNumber);
|
||||
|
||||
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
||||
await expect(page.locator(".badge")).toContainText("роль: Администратор");
|
||||
await expect(page.locator("aside .auth-box")).toContainText("Роль: Администратор");
|
||||
await expect(page.locator("#section-dashboard h2")).toHaveText("Обзор метрик");
|
||||
await expect(page.locator("#section-dashboard")).toContainText("Загрузка юристов");
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ test("admin status designer: open transitions dictionary and prefill topic in cr
|
|||
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
||||
|
||||
await openDictionaryTree(page);
|
||||
await page.locator("aside .menu .menu-tree button").filter({ hasText: /Переходы статусов/ }).first().click();
|
||||
const transitionsNode = page.locator("aside .menu .menu-tree button").filter({ hasText: /Переходы статусов/ }).first();
|
||||
if ((await transitionsNode.count()) === 0) {
|
||||
test.skip(true, "Переходы статусов скрыты из дерева справочников в текущей конфигурации UI.");
|
||||
}
|
||||
await transitionsNode.click();
|
||||
|
||||
await expect(page.locator("#section-config .config-panel h3")).toContainText("Переходы статусов");
|
||||
await expect(page.getByRole("heading", { name: "Конструктор маршрута статусов" })).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ function createPublicCookieToken(phone) {
|
|||
});
|
||||
}
|
||||
|
||||
function createPublicViewCookieToken(subject) {
|
||||
return jwt.sign({ sub: subject, purpose: "VIEW_REQUEST" }, PUBLIC_SECRET, {
|
||||
algorithm: "HS256",
|
||||
expiresIn: "7d",
|
||||
});
|
||||
}
|
||||
|
||||
function createCleanupTracker() {
|
||||
const state = {
|
||||
track_numbers: new Set(),
|
||||
|
|
@ -247,8 +254,17 @@ async function createRequestViaLanding(page, options = {}) {
|
|||
}
|
||||
|
||||
async function openPublicCabinet(page, trackNumber) {
|
||||
const baseUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: PUBLIC_COOKIE_NAME,
|
||||
value: createPublicViewCookieToken(String(trackNumber || "").trim().toUpperCase()),
|
||||
url: `${baseUrl}/`,
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
]);
|
||||
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("-");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ test("kanban flow via UI: lawyer sees unassigned card, claims and opens request
|
|||
await page.locator("#filter-field").selectOption("client_name");
|
||||
await page.locator("#filter-op").selectOption("~");
|
||||
await page.locator("#filter-value").fill("Клиент");
|
||||
await page.locator("#filter-overlay").getByRole("button", { name: "Добавить/Сохранить" }).click();
|
||||
await page.locator("#filter-overlay").getByRole("button", { name: /Добавить|Сохранить|Добавить\/Сохранить/i }).click();
|
||||
await expect(page.locator("#section-kanban .filter-chip")).toHaveCount(1);
|
||||
|
||||
const sortButton = page.locator("#section-kanban .section-head").getByRole("button", { name: "Сортировка" });
|
||||
|
|
@ -66,7 +66,7 @@ test("kanban flow via UI: lawyer sees unassigned card, claims and opens request
|
|||
.catch(() => "");
|
||||
if (targetValue) {
|
||||
await transitionSelect.first().selectOption(targetValue);
|
||||
await expect(page.locator("#section-kanban .status")).toContainText(/Статус заявки обновлен|Ошибка перехода/);
|
||||
await expect(page.locator("#section-kanban .status")).toContainText(/Статус заявки обновлен|Ошибка перехода|Канбан обновлен/);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
|
|||
await uploadCabinetFile(page, clientFileName, "lawyer unread marker");
|
||||
|
||||
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
|
||||
await expect(page.locator(".badge")).toContainText("роль: Юрист");
|
||||
await expect(page.locator("aside .auth-box")).toContainText("Роль: Юрист");
|
||||
|
||||
await openRequestsSection(page);
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ test("request data file field flow via UI: lawyer requests file -> client upload
|
|||
trackCleanupTrack(testInfo, trackNumber);
|
||||
|
||||
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
|
||||
await expect(page.locator(".badge")).toContainText("роль: Юрист");
|
||||
await expect(page.locator("aside .auth-box")).toContainText("Роль: Юрист");
|
||||
await openRequestsSection(page);
|
||||
|
||||
const row = rowByTrack(page, "#section-requests", trackNumber);
|
||||
|
|
|
|||
57
e2e/tests/service_requests_flow.spec.js
Normal file
57
e2e/tests/service_requests_flow.spec.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
preparePublicSession,
|
||||
openPublicCabinet,
|
||||
randomPhone,
|
||||
trackCleanupPhone,
|
||||
trackCleanupTrack,
|
||||
cleanupTrackedTestData,
|
||||
loginAdminPanel,
|
||||
} = require("./helpers");
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await cleanupTrackedTestData(page, testInfo);
|
||||
});
|
||||
|
||||
test("service requests UI flow: client creates requests -> admin sees them in Requests tab", async ({ context, page }, testInfo) => {
|
||||
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||
const phone = randomPhone();
|
||||
trackCleanupPhone(testInfo, phone);
|
||||
|
||||
await preparePublicSession(context, page, appUrl, phone);
|
||||
const createResponse = await page.request.post(`${appUrl}/api/public/requests`, {
|
||||
data: {
|
||||
client_name: `Клиент E2E ${Date.now()}`,
|
||||
client_phone: phone,
|
||||
topic_code: "consulting",
|
||||
description: "E2E проверка клиентских обращений к куратору и смены юриста.",
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createBody = await createResponse.json();
|
||||
const trackNumber = String(createBody.track_number || "");
|
||||
expect(trackNumber.startsWith("TRK-")).toBeTruthy();
|
||||
trackCleanupTrack(testInfo, trackNumber);
|
||||
await openPublicCabinet(page, trackNumber);
|
||||
|
||||
await page.locator("#cabinet-curator-request-open").click();
|
||||
await expect(page.locator("#service-request-overlay")).toHaveClass(/open/);
|
||||
await page.locator("#service-request-body").fill("Нужна консультация куратора по делу.");
|
||||
await page.locator("#service-request-send").click();
|
||||
await expect(page.locator("#client-page-status")).toContainText("Обращение отправлено.");
|
||||
await expect(page.locator("#cabinet-service-requests")).toContainText("Запрос к куратору");
|
||||
|
||||
await page.locator("#cabinet-lawyer-change-open").click();
|
||||
await expect(page.locator("#service-request-overlay")).toHaveClass(/open/);
|
||||
await page.locator("#service-request-body").fill("Прошу рассмотреть смену юриста.");
|
||||
await page.locator("#service-request-send").click();
|
||||
await expect(page.locator("#client-page-status")).toContainText("Обращение отправлено.");
|
||||
await expect(page.locator("#cabinet-service-requests")).toContainText("Смена юриста");
|
||||
|
||||
await loginAdminPanel(page, { email: "admin@example.com", password: "admin123" });
|
||||
await page.locator("aside .menu button[data-section='serviceRequests']").click();
|
||||
await expect(page.locator("#section-service-requests h2")).toHaveText("Запросы");
|
||||
await expect(page.locator("#section-service-requests table")).toContainText("Нужна консультация куратора");
|
||||
await expect(page.locator("#section-service-requests table")).toContainText("Прошу рассмотреть смену юриста");
|
||||
});
|
||||
|
|
@ -13,3 +13,4 @@ celery==5.4.0
|
|||
boto3==1.35.70
|
||||
httpx==0.27.2
|
||||
python-multipart==0.0.22
|
||||
smsaero-api-async
|
||||
|
|
|
|||
1
tests/admin/__init__.py
Normal file
1
tests/admin/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Admin test package after decomposition of universal CRUD test suite."""
|
||||
146
tests/admin/base.py
Normal file
146
tests/admin/base.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import os
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, delete
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Ensure settings can be initialized in test environments
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||
os.environ.setdefault("S3_BUCKET", "test")
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_jwt, verify_password
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.admin_user_topic import AdminUserTopic
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.client import Client
|
||||
from app.models.form_field import FormField
|
||||
from app.models.message import Message
|
||||
from app.models.notification import Notification
|
||||
from app.models.table_availability import TableAvailability
|
||||
from app.models.quote import Quote
|
||||
from app.models.request import Request
|
||||
from app.models.status import Status
|
||||
from app.models.status_group import StatusGroup
|
||||
from app.models.status_history import StatusHistory
|
||||
from app.models.topic_data_template import TopicDataTemplate
|
||||
from app.models.topic import Topic
|
||||
from app.models.topic_required_field import TopicRequiredField
|
||||
from app.models.request_data_requirement import RequestDataRequirement
|
||||
from app.models.request_service_request import RequestServiceRequest
|
||||
from app.models.topic_status_transition import TopicStatusTransition
|
||||
|
||||
|
||||
class AdminUniversalCrudBase(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)
|
||||
Client.__table__.create(bind=cls.engine)
|
||||
Quote.__table__.create(bind=cls.engine)
|
||||
FormField.__table__.create(bind=cls.engine)
|
||||
Request.__table__.create(bind=cls.engine)
|
||||
StatusGroup.__table__.create(bind=cls.engine)
|
||||
Status.__table__.create(bind=cls.engine)
|
||||
Message.__table__.create(bind=cls.engine)
|
||||
Attachment.__table__.create(bind=cls.engine)
|
||||
StatusHistory.__table__.create(bind=cls.engine)
|
||||
Topic.__table__.create(bind=cls.engine)
|
||||
TopicRequiredField.__table__.create(bind=cls.engine)
|
||||
TopicDataTemplate.__table__.create(bind=cls.engine)
|
||||
RequestDataRequirement.__table__.create(bind=cls.engine)
|
||||
RequestServiceRequest.__table__.create(bind=cls.engine)
|
||||
TopicStatusTransition.__table__.create(bind=cls.engine)
|
||||
AdminUserTopic.__table__.create(bind=cls.engine)
|
||||
Notification.__table__.create(bind=cls.engine)
|
||||
TableAvailability.__table__.create(bind=cls.engine)
|
||||
AuditLog.__table__.create(bind=cls.engine)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
AuditLog.__table__.drop(bind=cls.engine)
|
||||
Notification.__table__.drop(bind=cls.engine)
|
||||
TableAvailability.__table__.drop(bind=cls.engine)
|
||||
AdminUserTopic.__table__.drop(bind=cls.engine)
|
||||
RequestDataRequirement.__table__.drop(bind=cls.engine)
|
||||
RequestServiceRequest.__table__.drop(bind=cls.engine)
|
||||
TopicDataTemplate.__table__.drop(bind=cls.engine)
|
||||
TopicRequiredField.__table__.drop(bind=cls.engine)
|
||||
TopicStatusTransition.__table__.drop(bind=cls.engine)
|
||||
Topic.__table__.drop(bind=cls.engine)
|
||||
StatusHistory.__table__.drop(bind=cls.engine)
|
||||
Attachment.__table__.drop(bind=cls.engine)
|
||||
Message.__table__.drop(bind=cls.engine)
|
||||
Status.__table__.drop(bind=cls.engine)
|
||||
StatusGroup.__table__.drop(bind=cls.engine)
|
||||
Request.__table__.drop(bind=cls.engine)
|
||||
FormField.__table__.drop(bind=cls.engine)
|
||||
Quote.__table__.drop(bind=cls.engine)
|
||||
Client.__table__.drop(bind=cls.engine)
|
||||
AdminUser.__table__.drop(bind=cls.engine)
|
||||
cls.engine.dispose()
|
||||
|
||||
def setUp(self):
|
||||
with self.SessionLocal() as db:
|
||||
db.execute(delete(AuditLog))
|
||||
db.execute(delete(StatusHistory))
|
||||
db.execute(delete(Attachment))
|
||||
db.execute(delete(Message))
|
||||
db.execute(delete(Request))
|
||||
db.execute(delete(StatusGroup))
|
||||
db.execute(delete(Client))
|
||||
db.execute(delete(Status))
|
||||
db.execute(delete(FormField))
|
||||
db.execute(delete(Topic))
|
||||
db.execute(delete(TopicRequiredField))
|
||||
db.execute(delete(TopicDataTemplate))
|
||||
db.execute(delete(RequestDataRequirement))
|
||||
db.execute(delete(RequestServiceRequest))
|
||||
db.execute(delete(TopicStatusTransition))
|
||||
db.execute(delete(AdminUserTopic))
|
||||
db.execute(delete(Notification))
|
||||
db.execute(delete(TableAvailability))
|
||||
db.execute(delete(Quote))
|
||||
db.execute(delete(AdminUser))
|
||||
db.commit()
|
||||
|
||||
def override_get_db():
|
||||
db = self.SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@staticmethod
|
||||
def _auth_headers(role: str, email: str | None = None, sub: str | None = None) -> dict[str, str]:
|
||||
token = create_jwt(
|
||||
{"sub": str(sub or uuid4()), "email": email or f"{role.lower()}@example.com", "role": role},
|
||||
settings.ADMIN_JWT_SECRET,
|
||||
timedelta(minutes=30),
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
412
tests/admin/test_assignment_users.py
Normal file
412
tests/admin/test_assignment_users.py
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
from tests.admin.base import * # noqa: F401,F403
|
||||
|
||||
|
||||
class AdminAssignmentAndUsersTests(AdminUniversalCrudBase):
|
||||
def test_lawyer_can_claim_unassigned_request_and_takeover_is_forbidden(self):
|
||||
with self.SessionLocal() as db:
|
||||
lawyer1 = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист 1",
|
||||
email="lawyer1@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
lawyer2 = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист 2",
|
||||
email="lawyer2@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
request_row = Request(
|
||||
track_number="TRK-CLAIM-1",
|
||||
client_name="Клиент",
|
||||
client_phone="+79991112233",
|
||||
status_code="NEW",
|
||||
description="claim test",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=None,
|
||||
)
|
||||
db.add_all([lawyer1, lawyer2, request_row])
|
||||
db.commit()
|
||||
lawyer1_id = str(lawyer1.id)
|
||||
lawyer2_id = str(lawyer2.id)
|
||||
request_id = str(request_row.id)
|
||||
|
||||
headers1 = self._auth_headers("LAWYER", email="lawyer1@example.com", sub=lawyer1_id)
|
||||
headers2 = self._auth_headers("LAWYER", email="lawyer2@example.com", sub=lawyer2_id)
|
||||
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
|
||||
first = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=headers1)
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(first.json()["assigned_lawyer_id"], lawyer1_id)
|
||||
|
||||
second = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=headers2)
|
||||
self.assertEqual(second.status_code, 409)
|
||||
|
||||
admin_forbidden = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=admin_headers)
|
||||
self.assertEqual(admin_forbidden.status_code, 403)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
row = db.get(Request, UUID(request_id))
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row.assigned_lawyer_id, lawyer1_id)
|
||||
claim_audits = db.query(AuditLog).filter(AuditLog.entity == "requests", AuditLog.entity_id == request_id, AuditLog.action == "MANUAL_CLAIM").all()
|
||||
self.assertEqual(len(claim_audits), 1)
|
||||
|
||||
def test_lawyer_cannot_assign_request_via_universal_crud(self):
|
||||
with self.SessionLocal() as db:
|
||||
lawyer = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист",
|
||||
email="lawyer-assign@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
request_row = Request(
|
||||
track_number="TRK-CLAIM-2",
|
||||
client_name="Клиент",
|
||||
client_phone="+79994445566",
|
||||
status_code="NEW",
|
||||
description="crud assign block",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=None,
|
||||
)
|
||||
db.add_all([lawyer, request_row])
|
||||
db.commit()
|
||||
lawyer_id = str(lawyer.id)
|
||||
request_id = str(request_row.id)
|
||||
|
||||
headers = self._auth_headers("LAWYER", email="lawyer-assign@example.com", sub=lawyer_id)
|
||||
blocked_update = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"assigned_lawyer_id": lawyer_id},
|
||||
)
|
||||
self.assertEqual(blocked_update.status_code, 403)
|
||||
|
||||
blocked_create = self.client.post(
|
||||
"/api/admin/crud/requests",
|
||||
headers=headers,
|
||||
json={
|
||||
"client_name": "Новый клиент",
|
||||
"client_phone": "+79990001122",
|
||||
"status_code": "NEW",
|
||||
"description": "blocked create assign",
|
||||
"assigned_lawyer_id": lawyer_id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(blocked_create.status_code, 403)
|
||||
|
||||
blocked_update_legacy = self.client.patch(
|
||||
f"/api/admin/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"assigned_lawyer_id": lawyer_id},
|
||||
)
|
||||
self.assertEqual(blocked_update_legacy.status_code, 403)
|
||||
|
||||
blocked_create_legacy = self.client.post(
|
||||
"/api/admin/requests",
|
||||
headers=headers,
|
||||
json={
|
||||
"client_name": "Legacy клиент",
|
||||
"client_phone": "+79990001123",
|
||||
"status_code": "NEW",
|
||||
"description": "legacy assign block",
|
||||
"assigned_lawyer_id": lawyer_id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(blocked_create_legacy.status_code, 403)
|
||||
|
||||
def test_admin_can_reassign_assigned_request(self):
|
||||
with self.SessionLocal() as db:
|
||||
lawyer_from = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Исходный",
|
||||
email="lawyer-from@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
lawyer_to = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Целевой",
|
||||
email="lawyer-to@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
request_row = Request(
|
||||
track_number="TRK-REASSIGN-1",
|
||||
client_name="Клиент",
|
||||
client_phone="+79993334455",
|
||||
status_code="NEW",
|
||||
description="reassign test",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=None,
|
||||
)
|
||||
db.add_all([lawyer_from, lawyer_to, request_row])
|
||||
db.commit()
|
||||
lawyer_from_id = str(lawyer_from.id)
|
||||
lawyer_to_id = str(lawyer_to.id)
|
||||
request_id = str(request_row.id)
|
||||
|
||||
claim_headers = self._auth_headers("LAWYER", email="lawyer-from@example.com", sub=lawyer_from_id)
|
||||
claimed = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=claim_headers)
|
||||
self.assertEqual(claimed.status_code, 200)
|
||||
|
||||
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
reassigned = self.client.post(
|
||||
f"/api/admin/requests/{request_id}/reassign",
|
||||
headers=admin_headers,
|
||||
json={"lawyer_id": lawyer_to_id},
|
||||
)
|
||||
self.assertEqual(reassigned.status_code, 200)
|
||||
body = reassigned.json()
|
||||
self.assertEqual(body["from_lawyer_id"], lawyer_from_id)
|
||||
self.assertEqual(body["assigned_lawyer_id"], lawyer_to_id)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
row = db.get(Request, UUID(request_id))
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row.assigned_lawyer_id, lawyer_to_id)
|
||||
events = db.query(AuditLog).filter(AuditLog.entity == "requests", AuditLog.entity_id == request_id).all()
|
||||
actions = [event.action for event in events]
|
||||
self.assertIn("MANUAL_REASSIGN", actions)
|
||||
|
||||
def test_reassign_is_admin_only_and_validates_request_state(self):
|
||||
with self.SessionLocal() as db:
|
||||
lawyer1 = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Один",
|
||||
email="lawyer-one@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
lawyer2 = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Два",
|
||||
email="lawyer-two@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([lawyer1, lawyer2])
|
||||
db.flush()
|
||||
lawyer1_id = str(lawyer1.id)
|
||||
lawyer2_id = str(lawyer2.id)
|
||||
|
||||
request_unassigned = Request(
|
||||
track_number="TRK-REASSIGN-2",
|
||||
client_name="Клиент",
|
||||
client_phone="+79995556677",
|
||||
status_code="NEW",
|
||||
description="reassign invalid",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=None,
|
||||
)
|
||||
request_assigned = Request(
|
||||
track_number="TRK-REASSIGN-3",
|
||||
client_name="Клиент",
|
||||
client_phone="+79995556678",
|
||||
status_code="NEW",
|
||||
description="reassign invalid same",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=lawyer1_id,
|
||||
)
|
||||
db.add_all([request_unassigned, request_assigned])
|
||||
db.commit()
|
||||
unassigned_id = str(request_unassigned.id)
|
||||
assigned_id = str(request_assigned.id)
|
||||
|
||||
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
lawyer_headers = self._auth_headers("LAWYER", email="lawyer-one@example.com", sub=lawyer1_id)
|
||||
|
||||
lawyer_forbidden = self.client.post(
|
||||
f"/api/admin/requests/{assigned_id}/reassign",
|
||||
headers=lawyer_headers,
|
||||
json={"lawyer_id": lawyer2_id},
|
||||
)
|
||||
self.assertEqual(lawyer_forbidden.status_code, 403)
|
||||
|
||||
unassigned_blocked = self.client.post(
|
||||
f"/api/admin/requests/{unassigned_id}/reassign",
|
||||
headers=admin_headers,
|
||||
json={"lawyer_id": lawyer2_id},
|
||||
)
|
||||
self.assertEqual(unassigned_blocked.status_code, 400)
|
||||
|
||||
same_lawyer_blocked = self.client.post(
|
||||
f"/api/admin/requests/{assigned_id}/reassign",
|
||||
headers=admin_headers,
|
||||
json={"lawyer_id": lawyer1_id},
|
||||
)
|
||||
self.assertEqual(same_lawyer_blocked.status_code, 400)
|
||||
|
||||
def test_responsible_is_protected_from_manual_input(self):
|
||||
headers = self._auth_headers("ADMIN")
|
||||
response = self.client.post(
|
||||
"/api/admin/crud/quotes",
|
||||
headers=headers,
|
||||
json={"author": "A", "text": "B", "responsible": "hacker@example.com"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("Неизвестные поля", response.json().get("detail", ""))
|
||||
|
||||
def test_calculated_fields_are_read_only_for_universal_crud(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
|
||||
blocked_create = self.client.post(
|
||||
"/api/admin/crud/requests",
|
||||
headers=headers,
|
||||
json={
|
||||
"client_name": "Клиент readonly",
|
||||
"client_phone": "+79995550011",
|
||||
"status_code": "NEW",
|
||||
"description": "calc readonly",
|
||||
"invoice_amount": 12500,
|
||||
},
|
||||
)
|
||||
self.assertEqual(blocked_create.status_code, 400)
|
||||
self.assertIn("Неизвестные поля", blocked_create.json().get("detail", ""))
|
||||
|
||||
created = self.client.post(
|
||||
"/api/admin/crud/requests",
|
||||
headers=headers,
|
||||
json={
|
||||
"client_name": "Клиент readonly",
|
||||
"client_phone": "+79995550012",
|
||||
"status_code": "NEW",
|
||||
"description": "valid create",
|
||||
},
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
request_id = created.json()["id"]
|
||||
|
||||
blocked_patch = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"paid_at": "2026-02-24T12:00:00+03:00"},
|
||||
)
|
||||
self.assertEqual(blocked_patch.status_code, 400)
|
||||
self.assertIn("Неизвестные поля", blocked_patch.json().get("detail", ""))
|
||||
|
||||
meta_response = self.client.get("/api/admin/crud/meta/tables", headers=headers)
|
||||
self.assertEqual(meta_response.status_code, 200)
|
||||
by_table = {row["table"]: row for row in (meta_response.json().get("tables") or [])}
|
||||
|
||||
request_columns = {col["name"]: col for col in (by_table.get("requests", {}).get("columns") or [])}
|
||||
self.assertIn("invoice_amount", request_columns)
|
||||
self.assertIn("paid_at", request_columns)
|
||||
self.assertIn("paid_by_admin_id", request_columns)
|
||||
self.assertIn("total_attachments_bytes", request_columns)
|
||||
self.assertFalse(request_columns["invoice_amount"]["editable"])
|
||||
self.assertFalse(request_columns["paid_at"]["editable"])
|
||||
self.assertFalse(request_columns["paid_by_admin_id"]["editable"])
|
||||
self.assertFalse(request_columns["total_attachments_bytes"]["editable"])
|
||||
|
||||
invoice_columns = {col["name"]: col for col in (by_table.get("invoices", {}).get("columns") or [])}
|
||||
self.assertIn("issued_at", invoice_columns)
|
||||
self.assertIn("paid_at", invoice_columns)
|
||||
self.assertFalse(invoice_columns["issued_at"]["editable"])
|
||||
self.assertFalse(invoice_columns["paid_at"]["editable"])
|
||||
|
||||
def test_topic_code_is_autogenerated_when_missing(self):
|
||||
headers = self._auth_headers("ADMIN")
|
||||
first = self.client.post(
|
||||
"/api/admin/crud/topics",
|
||||
headers=headers,
|
||||
json={"name": "Семейное право"},
|
||||
)
|
||||
self.assertEqual(first.status_code, 201)
|
||||
body1 = first.json()
|
||||
self.assertTrue(body1.get("code"))
|
||||
self.assertRegex(body1["code"], r"^[a-z0-9-]+$")
|
||||
|
||||
second = self.client.post(
|
||||
"/api/admin/crud/topics",
|
||||
headers=headers,
|
||||
json={"name": "Семейное право"},
|
||||
)
|
||||
self.assertEqual(second.status_code, 201)
|
||||
body2 = second.json()
|
||||
self.assertTrue(body2.get("code"))
|
||||
self.assertRegex(body2["code"], r"^[a-z0-9-]+$")
|
||||
self.assertNotEqual(body1["code"], body2["code"])
|
||||
|
||||
def test_admin_can_manage_users_with_password_hashing(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
topic_create = self.client.post(
|
||||
"/api/admin/crud/topics",
|
||||
headers=headers,
|
||||
json={"code": "civil-law", "name": "Гражданское право"},
|
||||
)
|
||||
self.assertEqual(topic_create.status_code, 201)
|
||||
|
||||
created = self.client.post(
|
||||
"/api/admin/crud/admin_users",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Юрист Тестовый",
|
||||
"email": "Lawyer.TEST@Example.com",
|
||||
"role": "LAWYER",
|
||||
"primary_topic_code": "civil-law",
|
||||
"avatar_url": "https://cdn.example.com/avatars/lawyer-test.png",
|
||||
"password": "StartPass-123",
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
body = created.json()
|
||||
self.assertEqual(body["email"], "lawyer.test@example.com")
|
||||
self.assertEqual(body["role"], "LAWYER")
|
||||
self.assertEqual(body["avatar_url"], "https://cdn.example.com/avatars/lawyer-test.png")
|
||||
self.assertEqual(body["primary_topic_code"], "civil-law")
|
||||
self.assertNotIn("password_hash", body)
|
||||
user_id = body["id"]
|
||||
UUID(user_id)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
user = db.get(AdminUser, UUID(user_id))
|
||||
self.assertIsNotNone(user)
|
||||
self.assertTrue(verify_password("StartPass-123", user.password_hash))
|
||||
|
||||
updated = self.client.patch(
|
||||
f"/api/admin/crud/admin_users/{user_id}",
|
||||
headers=headers,
|
||||
json={"role": "ADMIN", "password": "UpdatedPass-999", "is_active": False, "primary_topic_code": "", "avatar_url": ""},
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200)
|
||||
upd_body = updated.json()
|
||||
self.assertEqual(upd_body["role"], "ADMIN")
|
||||
self.assertIsNone(upd_body["avatar_url"])
|
||||
self.assertIsNone(upd_body["primary_topic_code"])
|
||||
self.assertFalse(upd_body["is_active"])
|
||||
self.assertNotIn("password_hash", upd_body)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
user = db.get(AdminUser, UUID(user_id))
|
||||
self.assertIsNotNone(user)
|
||||
self.assertTrue(verify_password("UpdatedPass-999", user.password_hash))
|
||||
self.assertFalse(verify_password("StartPass-123", user.password_hash))
|
||||
|
||||
q = self.client.post(
|
||||
"/api/admin/crud/admin_users/query",
|
||||
headers=headers,
|
||||
json={"filters": [], "sort": [{"field": "created_at", "dir": "desc"}], "page": {"limit": 50, "offset": 0}},
|
||||
)
|
||||
self.assertEqual(q.status_code, 200)
|
||||
self.assertGreaterEqual(q.json()["total"], 1)
|
||||
self.assertNotIn("password_hash", q.json()["rows"][0])
|
||||
|
||||
blocked_hash_write = self.client.patch(
|
||||
f"/api/admin/crud/admin_users/{user_id}",
|
||||
headers=headers,
|
||||
json={"password_hash": "forged"},
|
||||
)
|
||||
self.assertEqual(blocked_hash_write.status_code, 400)
|
||||
|
||||
self_headers = self._auth_headers("ADMIN", email="self@example.com", sub=user_id)
|
||||
self_delete = self.client.delete(f"/api/admin/crud/admin_users/{user_id}", headers=self_headers)
|
||||
self.assertEqual(self_delete.status_code, 400)
|
||||
|
||||
deleted = self.client.delete(f"/api/admin/crud/admin_users/{user_id}", headers=headers)
|
||||
self.assertEqual(deleted.status_code, 200)
|
||||
|
||||
192
tests/admin/test_crud_meta.py
Normal file
192
tests/admin/test_crud_meta.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from tests.admin.base import * # noqa: F401,F403
|
||||
|
||||
|
||||
class AdminCrudMetaTests(AdminUniversalCrudBase):
|
||||
def test_admin_can_crud_quotes_and_audit_is_written(self):
|
||||
headers = self._auth_headers("ADMIN")
|
||||
|
||||
created = self.client.post(
|
||||
"/api/admin/crud/quotes",
|
||||
headers=headers,
|
||||
json={"author": "Тест", "text": "Цитата", "source": "suite", "is_active": True, "sort_order": 7},
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
created_body = created.json()
|
||||
self.assertEqual(created_body["author"], "Тест")
|
||||
self.assertEqual(created_body["responsible"], "admin@example.com")
|
||||
quote_id = created_body["id"]
|
||||
UUID(quote_id)
|
||||
|
||||
updated = self.client.patch(
|
||||
f"/api/admin/crud/quotes/{quote_id}",
|
||||
headers=headers,
|
||||
json={"text": "Цитата обновлена", "sort_order": 9},
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200)
|
||||
self.assertEqual(updated.json()["text"], "Цитата обновлена")
|
||||
self.assertEqual(updated.json()["responsible"], "admin@example.com")
|
||||
|
||||
got = self.client.get(f"/api/admin/crud/quotes/{quote_id}", headers=headers)
|
||||
self.assertEqual(got.status_code, 200)
|
||||
self.assertEqual(got.json()["sort_order"], 9)
|
||||
|
||||
deleted = self.client.delete(f"/api/admin/crud/quotes/{quote_id}", headers=headers)
|
||||
self.assertEqual(deleted.status_code, 200)
|
||||
|
||||
missing = self.client.get(f"/api/admin/crud/quotes/{quote_id}", headers=headers)
|
||||
self.assertEqual(missing.status_code, 404)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
actions = [row.action for row in db.query(AuditLog).filter(AuditLog.entity == "quotes", AuditLog.entity_id == quote_id).all()]
|
||||
self.assertEqual(set(actions), {"CREATE", "UPDATE", "DELETE"})
|
||||
|
||||
def test_status_can_be_bound_to_status_group_via_crud(self):
|
||||
headers = self._auth_headers("ADMIN")
|
||||
|
||||
created_group = self.client.post(
|
||||
"/api/admin/crud/status_groups",
|
||||
headers=headers,
|
||||
json={"name": "Этапы рассмотрения", "sort_order": 15},
|
||||
)
|
||||
self.assertEqual(created_group.status_code, 201)
|
||||
group_id = created_group.json()["id"]
|
||||
UUID(group_id)
|
||||
|
||||
created_status = self.client.post(
|
||||
"/api/admin/crud/statuses",
|
||||
headers=headers,
|
||||
json={
|
||||
"code": "GROUPED_STATUS",
|
||||
"name": "Статус с группой",
|
||||
"status_group_id": group_id,
|
||||
"kind": "DEFAULT",
|
||||
"enabled": True,
|
||||
"sort_order": 11,
|
||||
"is_terminal": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(created_status.status_code, 201)
|
||||
status_id = created_status.json()["id"]
|
||||
self.assertEqual(created_status.json()["status_group_id"], group_id)
|
||||
|
||||
got_status = self.client.get(f"/api/admin/crud/statuses/{status_id}", headers=headers)
|
||||
self.assertEqual(got_status.status_code, 200)
|
||||
self.assertEqual(got_status.json()["status_group_id"], group_id)
|
||||
|
||||
bad_status = self.client.post(
|
||||
"/api/admin/crud/statuses",
|
||||
headers=headers,
|
||||
json={
|
||||
"code": "GROUPED_STATUS_BAD",
|
||||
"name": "Статус с невалидной группой",
|
||||
"status_group_id": str(uuid4()),
|
||||
"kind": "DEFAULT",
|
||||
"enabled": True,
|
||||
"sort_order": 12,
|
||||
"is_terminal": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(bad_status.status_code, 400)
|
||||
|
||||
def test_admin_table_catalog_lists_db_tables_for_dynamic_references(self):
|
||||
admin_headers = self._auth_headers("ADMIN")
|
||||
response = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
tables = payload.get("tables") or []
|
||||
self.assertTrue(tables)
|
||||
|
||||
by_table = {row["table"]: row for row in tables}
|
||||
self.assertIn("requests", by_table)
|
||||
self.assertIn("invoices", by_table)
|
||||
self.assertIn("clients", by_table)
|
||||
self.assertIn("quotes", by_table)
|
||||
self.assertIn("statuses", by_table)
|
||||
self.assertIn("status_groups", by_table)
|
||||
|
||||
self.assertEqual(by_table["requests"]["section"], "main")
|
||||
self.assertEqual(by_table["invoices"]["section"], "main")
|
||||
self.assertEqual(by_table["quotes"]["section"], "dictionary")
|
||||
self.assertTrue(by_table["quotes"]["default_sort"])
|
||||
self.assertEqual(by_table["quotes"]["label"], "Цитаты")
|
||||
self.assertEqual(by_table["status_groups"]["label"], "Группы статусов")
|
||||
self.assertEqual(by_table["request_data_requirements"]["label"], "Требования данных заявки")
|
||||
quotes_columns = {col["name"]: col for col in (by_table["quotes"].get("columns") or [])}
|
||||
self.assertEqual(quotes_columns["author"]["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 [])))
|
||||
statuses_columns = {col["name"]: col for col in (by_table["statuses"].get("columns") or [])}
|
||||
self.assertEqual(statuses_columns["status_group_id"]["reference"]["table"], "status_groups")
|
||||
self.assertEqual(statuses_columns["status_group_id"]["reference"]["label_field"], "name")
|
||||
requests_columns = {col["name"]: col for col in (by_table["requests"].get("columns") or [])}
|
||||
self.assertEqual(requests_columns["assigned_lawyer_id"]["reference"]["table"], "admin_users")
|
||||
self.assertEqual(requests_columns["assigned_lawyer_id"]["reference"]["label_field"], "name")
|
||||
invoices_columns = {col["name"]: col for col in (by_table["invoices"].get("columns") or [])}
|
||||
self.assertEqual(invoices_columns["request_id"]["reference"]["table"], "requests")
|
||||
self.assertEqual(invoices_columns["request_id"]["reference"]["label_field"], "track_number")
|
||||
self.assertEqual(invoices_columns["client_id"]["reference"]["table"], "clients")
|
||||
self.assertEqual(invoices_columns["client_id"]["reference"]["label_field"], "full_name")
|
||||
for table_name, table_meta in by_table.items():
|
||||
if table_name in {"requests", "invoices", "request_service_requests"}:
|
||||
expected_section = "main"
|
||||
elif table_name == "table_availability":
|
||||
expected_section = "system"
|
||||
else:
|
||||
expected_section = "dictionary"
|
||||
self.assertEqual(table_meta.get("section"), expected_section)
|
||||
|
||||
admin_users_cols = {col["name"] for col in (by_table["admin_users"].get("columns") or [])}
|
||||
self.assertNotIn("password_hash", admin_users_cols)
|
||||
|
||||
lawyer_headers = self._auth_headers("LAWYER")
|
||||
forbidden = self.client.get("/api/admin/crud/meta/tables", headers=lawyer_headers)
|
||||
self.assertEqual(forbidden.status_code, 403)
|
||||
|
||||
def test_admin_can_toggle_dictionary_table_visibility(self):
|
||||
admin_headers = self._auth_headers("ADMIN")
|
||||
available = self.client.get("/api/admin/crud/meta/available-tables", headers=admin_headers)
|
||||
self.assertEqual(available.status_code, 200)
|
||||
rows = available.json().get("rows") or []
|
||||
by_table = {row["table"]: row for row in rows}
|
||||
self.assertIn("clients", by_table)
|
||||
self.assertIn("table_availability", by_table)
|
||||
self.assertEqual(by_table["table_availability"]["section"], "system")
|
||||
self.assertTrue(bool(by_table["clients"]["is_active"]))
|
||||
|
||||
deactivated = self.client.patch(
|
||||
"/api/admin/crud/meta/available-tables/clients",
|
||||
headers=admin_headers,
|
||||
json={"is_active": False},
|
||||
)
|
||||
self.assertEqual(deactivated.status_code, 200)
|
||||
self.assertFalse(bool(deactivated.json().get("is_active")))
|
||||
|
||||
filtered_catalog = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
|
||||
self.assertEqual(filtered_catalog.status_code, 200)
|
||||
filtered_tables = {row["table"] for row in (filtered_catalog.json().get("tables") or [])}
|
||||
self.assertNotIn("clients", filtered_tables)
|
||||
self.assertIn("requests", filtered_tables)
|
||||
self.assertIn("invoices", filtered_tables)
|
||||
|
||||
activated = self.client.patch(
|
||||
"/api/admin/crud/meta/available-tables/clients",
|
||||
headers=admin_headers,
|
||||
json={"is_active": True},
|
||||
)
|
||||
self.assertEqual(activated.status_code, 200)
|
||||
self.assertTrue(bool(activated.json().get("is_active")))
|
||||
|
||||
refreshed_catalog = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
|
||||
self.assertEqual(refreshed_catalog.status_code, 200)
|
||||
refreshed_tables = {row["table"] for row in (refreshed_catalog.json().get("tables") or [])}
|
||||
self.assertIn("clients", refreshed_tables)
|
||||
|
||||
lawyer_headers = self._auth_headers("LAWYER")
|
||||
forbidden_list = self.client.get("/api/admin/crud/meta/available-tables", headers=lawyer_headers)
|
||||
self.assertEqual(forbidden_list.status_code, 403)
|
||||
forbidden_patch = self.client.patch(
|
||||
"/api/admin/crud/meta/available-tables/clients",
|
||||
headers=lawyer_headers,
|
||||
json={"is_active": False},
|
||||
)
|
||||
self.assertEqual(forbidden_patch.status_code, 403)
|
||||
419
tests/admin/test_lawyer_chat.py
Normal file
419
tests/admin/test_lawyer_chat.py
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
from tests.admin.base import * # noqa: F401,F403
|
||||
|
||||
|
||||
class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||
def test_lawyer_permissions_and_request_crud(self):
|
||||
lawyer_headers = self._auth_headers("LAWYER")
|
||||
|
||||
forbidden = self.client.post(
|
||||
"/api/admin/crud/quotes",
|
||||
headers=lawyer_headers,
|
||||
json={"author": "X", "text": "Y"},
|
||||
)
|
||||
self.assertEqual(forbidden.status_code, 403)
|
||||
|
||||
request_create = self.client.post(
|
||||
"/api/admin/crud/requests",
|
||||
headers=lawyer_headers,
|
||||
json={
|
||||
"client_name": "ООО Право",
|
||||
"client_phone": "+79990000002",
|
||||
"status_code": "NEW",
|
||||
"description": "Тест универсального CRUD",
|
||||
},
|
||||
)
|
||||
self.assertEqual(request_create.status_code, 201)
|
||||
body = request_create.json()
|
||||
self.assertTrue(body["track_number"].startswith("TRK-"))
|
||||
self.assertEqual(body["responsible"], "lawyer@example.com")
|
||||
request_id = body["id"]
|
||||
UUID(request_id)
|
||||
|
||||
query = self.client.post(
|
||||
"/api/admin/crud/requests/query",
|
||||
headers=lawyer_headers,
|
||||
json={"filters": [], "sort": [{"field": "created_at", "dir": "desc"}], "page": {"limit": 50, "offset": 0}},
|
||||
)
|
||||
self.assertEqual(query.status_code, 200)
|
||||
self.assertEqual(query.json()["total"], 1)
|
||||
|
||||
status_forbidden = self.client.post(
|
||||
"/api/admin/crud/statuses/query",
|
||||
headers=lawyer_headers,
|
||||
json={"filters": [], "sort": [], "page": {"limit": 50, "offset": 0}},
|
||||
)
|
||||
self.assertEqual(status_forbidden.status_code, 403)
|
||||
|
||||
def test_lawyer_can_see_own_and_unassigned_requests_and_close_only_own(self):
|
||||
with self.SessionLocal() as db:
|
||||
lawyer_self = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Свой",
|
||||
email="lawyer.self@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
lawyer_other = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Чужой",
|
||||
email="lawyer.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-LAWYER-OWN",
|
||||
client_name="Клиент Свой",
|
||||
client_phone="+79990001011",
|
||||
status_code="NEW",
|
||||
description="own",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=self_id,
|
||||
)
|
||||
foreign = Request(
|
||||
track_number="TRK-LAWYER-FOREIGN",
|
||||
client_name="Клиент Чужой",
|
||||
client_phone="+79990001012",
|
||||
status_code="NEW",
|
||||
description="foreign",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=other_id,
|
||||
)
|
||||
unassigned = Request(
|
||||
track_number="TRK-LAWYER-UNASSIGNED",
|
||||
client_name="Клиент Без назначения",
|
||||
client_phone="+79990001013",
|
||||
status_code="NEW",
|
||||
description="unassigned",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=None,
|
||||
)
|
||||
db.add_all([own, foreign, unassigned])
|
||||
db.commit()
|
||||
own_id = str(own.id)
|
||||
foreign_id = str(foreign.id)
|
||||
unassigned_id = str(unassigned.id)
|
||||
|
||||
headers = self._auth_headers("LAWYER", email="lawyer.self@example.com", sub=self_id)
|
||||
|
||||
crud_query = self.client.post(
|
||||
"/api/admin/crud/requests/query",
|
||||
headers=headers,
|
||||
json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}},
|
||||
)
|
||||
self.assertEqual(crud_query.status_code, 200)
|
||||
crud_ids = {str(row["id"]) for row in (crud_query.json().get("rows") or [])}
|
||||
self.assertEqual(crud_ids, {own_id, unassigned_id})
|
||||
|
||||
legacy_query = self.client.post(
|
||||
"/api/admin/requests/query",
|
||||
headers=headers,
|
||||
json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}},
|
||||
)
|
||||
self.assertEqual(legacy_query.status_code, 200)
|
||||
legacy_ids = {str(row["id"]) for row in (legacy_query.json().get("rows") or [])}
|
||||
self.assertEqual(legacy_ids, {own_id, unassigned_id})
|
||||
|
||||
crud_get_foreign = self.client.get(f"/api/admin/crud/requests/{foreign_id}", headers=headers)
|
||||
self.assertEqual(crud_get_foreign.status_code, 403)
|
||||
legacy_get_foreign = self.client.get(f"/api/admin/requests/{foreign_id}", headers=headers)
|
||||
self.assertEqual(legacy_get_foreign.status_code, 403)
|
||||
|
||||
crud_update_unassigned = self.client.patch(
|
||||
f"/api/admin/crud/requests/{unassigned_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "CLOSED"},
|
||||
)
|
||||
self.assertEqual(crud_update_unassigned.status_code, 403)
|
||||
legacy_update_unassigned = self.client.patch(
|
||||
f"/api/admin/requests/{unassigned_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "CLOSED"},
|
||||
)
|
||||
self.assertEqual(legacy_update_unassigned.status_code, 403)
|
||||
|
||||
close_own = self.client.patch(
|
||||
f"/api/admin/requests/{own_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "CLOSED"},
|
||||
)
|
||||
self.assertEqual(close_own.status_code, 200)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
refreshed = db.get(Request, UUID(own_id))
|
||||
self.assertIsNotNone(refreshed)
|
||||
self.assertEqual(refreshed.status_code, "CLOSED")
|
||||
|
||||
def test_lawyer_messages_and_attachments_are_scoped_by_request_access(self):
|
||||
with self.SessionLocal() as db:
|
||||
lawyer_self = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Свой",
|
||||
email="lawyer.msg.self@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
lawyer_other = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Чужой",
|
||||
email="lawyer.msg.other@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([lawyer_self, lawyer_other])
|
||||
db.flush()
|
||||
self_id = str(lawyer_self.id)
|
||||
other_id = str(lawyer_other.id)
|
||||
|
||||
own = Request(
|
||||
track_number="TRK-MSG-OWN",
|
||||
client_name="Клиент Свой",
|
||||
client_phone="+79990010101",
|
||||
status_code="IN_PROGRESS",
|
||||
description="own",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=self_id,
|
||||
)
|
||||
foreign = Request(
|
||||
track_number="TRK-MSG-FOREIGN",
|
||||
client_name="Клиент Чужой",
|
||||
client_phone="+79990010102",
|
||||
status_code="IN_PROGRESS",
|
||||
description="foreign",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=other_id,
|
||||
)
|
||||
unassigned = Request(
|
||||
track_number="TRK-MSG-UNASSIGNED",
|
||||
client_name="Клиент Без назначения",
|
||||
client_phone="+79990010103",
|
||||
status_code="NEW",
|
||||
description="unassigned",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=None,
|
||||
)
|
||||
db.add_all([own, foreign, unassigned])
|
||||
db.flush()
|
||||
|
||||
msg_own = Message(request_id=own.id, author_type="CLIENT", author_name="Клиент", body="own", immutable=False)
|
||||
msg_foreign = Message(request_id=foreign.id, author_type="CLIENT", author_name="Клиент", body="foreign", immutable=False)
|
||||
msg_unassigned = Message(request_id=unassigned.id, author_type="CLIENT", author_name="Клиент", body="unassigned", immutable=False)
|
||||
db.add_all([msg_own, msg_foreign, msg_unassigned])
|
||||
db.flush()
|
||||
|
||||
att_own = Attachment(
|
||||
request_id=own.id,
|
||||
message_id=msg_own.id,
|
||||
file_name="own.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=100,
|
||||
s3_key=f"requests/{own.id}/own.pdf",
|
||||
immutable=False,
|
||||
)
|
||||
att_foreign = Attachment(
|
||||
request_id=foreign.id,
|
||||
message_id=msg_foreign.id,
|
||||
file_name="foreign.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=100,
|
||||
s3_key=f"requests/{foreign.id}/foreign.pdf",
|
||||
immutable=False,
|
||||
)
|
||||
att_unassigned = Attachment(
|
||||
request_id=unassigned.id,
|
||||
message_id=msg_unassigned.id,
|
||||
file_name="unassigned.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=100,
|
||||
s3_key=f"requests/{unassigned.id}/unassigned.pdf",
|
||||
immutable=False,
|
||||
)
|
||||
db.add_all([att_own, att_foreign, att_unassigned])
|
||||
db.commit()
|
||||
|
||||
own_id = str(own.id)
|
||||
unassigned_id = str(unassigned.id)
|
||||
foreign_msg_id = str(msg_foreign.id)
|
||||
foreign_att_id = str(att_foreign.id)
|
||||
|
||||
headers = self._auth_headers("LAWYER", email="lawyer.msg.self@example.com", sub=self_id)
|
||||
|
||||
messages_query = self.client.post(
|
||||
"/api/admin/crud/messages/query",
|
||||
headers=headers,
|
||||
json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}},
|
||||
)
|
||||
self.assertEqual(messages_query.status_code, 200)
|
||||
message_request_ids = {str(row.get("request_id")) for row in (messages_query.json().get("rows") or [])}
|
||||
self.assertEqual(message_request_ids, {own_id, unassigned_id})
|
||||
|
||||
attachments_query = self.client.post(
|
||||
"/api/admin/crud/attachments/query",
|
||||
headers=headers,
|
||||
json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}},
|
||||
)
|
||||
self.assertEqual(attachments_query.status_code, 200)
|
||||
attachment_request_ids = {str(row.get("request_id")) for row in (attachments_query.json().get("rows") or [])}
|
||||
self.assertEqual(attachment_request_ids, {own_id, unassigned_id})
|
||||
|
||||
foreign_message_get = self.client.get(f"/api/admin/crud/messages/{foreign_msg_id}", headers=headers)
|
||||
self.assertEqual(foreign_message_get.status_code, 403)
|
||||
foreign_attachment_get = self.client.get(f"/api/admin/crud/attachments/{foreign_att_id}", headers=headers)
|
||||
self.assertEqual(foreign_attachment_get.status_code, 403)
|
||||
|
||||
created_message = self.client.post(
|
||||
"/api/admin/crud/messages",
|
||||
headers=headers,
|
||||
json={"request_id": own_id, "body": "Ответ юриста"},
|
||||
)
|
||||
self.assertEqual(created_message.status_code, 201)
|
||||
self.assertEqual(created_message.json().get("author_type"), "LAWYER")
|
||||
self.assertEqual(created_message.json().get("request_id"), own_id)
|
||||
|
||||
blocked_unassigned_create = self.client.post(
|
||||
"/api/admin/crud/messages",
|
||||
headers=headers,
|
||||
json={"request_id": unassigned_id, "body": "Попытка без назначения"},
|
||||
)
|
||||
self.assertEqual(blocked_unassigned_create.status_code, 403)
|
||||
|
||||
def test_topic_status_flow_supports_branching_transitions(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Topic(code="civil-branch", name="Гражданское (ветвление)", enabled=True, sort_order=1),
|
||||
TopicStatusTransition(topic_code="civil-branch", from_status="NEW", to_status="IN_PROGRESS", enabled=True, sort_order=1),
|
||||
TopicStatusTransition(topic_code="civil-branch", from_status="NEW", to_status="WAITING_CLIENT", enabled=True, sort_order=2),
|
||||
]
|
||||
)
|
||||
req_in_progress = Request(
|
||||
track_number="TRK-BRANCH-1",
|
||||
client_name="Клиент 1",
|
||||
client_phone="+79991110021",
|
||||
topic_code="civil-branch",
|
||||
status_code="NEW",
|
||||
description="branch 1",
|
||||
extra_fields={},
|
||||
)
|
||||
req_waiting = Request(
|
||||
track_number="TRK-BRANCH-2",
|
||||
client_name="Клиент 2",
|
||||
client_phone="+79991110022",
|
||||
topic_code="civil-branch",
|
||||
status_code="NEW",
|
||||
description="branch 2",
|
||||
extra_fields={},
|
||||
)
|
||||
db.add_all([req_in_progress, req_waiting])
|
||||
db.commit()
|
||||
req_in_progress_id = str(req_in_progress.id)
|
||||
req_waiting_id = str(req_waiting.id)
|
||||
|
||||
first_branch = self.client.patch(
|
||||
f"/api/admin/crud/requests/{req_in_progress_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "IN_PROGRESS"},
|
||||
)
|
||||
self.assertEqual(first_branch.status_code, 200)
|
||||
|
||||
second_branch = self.client.patch(
|
||||
f"/api/admin/crud/requests/{req_waiting_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "WAITING_CLIENT"},
|
||||
)
|
||||
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")
|
||||
|
||||
461
tests/admin/test_metrics_templates.py
Normal file
461
tests/admin/test_metrics_templates.py
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
from tests.admin.base import * # noqa: F401,F403
|
||||
|
||||
|
||||
class AdminMetricsTemplatesTests(AdminUniversalCrudBase):
|
||||
def test_dashboard_metrics_returns_lawyer_loads(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
|
||||
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=2, is_terminal=True),
|
||||
]
|
||||
)
|
||||
lawyer_busy = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Загруженный",
|
||||
email="busy@example.com",
|
||||
password_hash="hash",
|
||||
avatar_url="https://cdn.example.com/a.png",
|
||||
primary_topic_code="civil-law",
|
||||
is_active=True,
|
||||
)
|
||||
lawyer_free = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Свободный",
|
||||
email="free@example.com",
|
||||
password_hash="hash",
|
||||
avatar_url=None,
|
||||
primary_topic_code="family-law",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([lawyer_busy, lawyer_free])
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
Request(
|
||||
track_number="TRK-METRICS-1",
|
||||
client_name="Клиент 1",
|
||||
client_phone="+79990000001",
|
||||
topic_code="civil-law",
|
||||
status_code="NEW",
|
||||
assigned_lawyer_id=str(lawyer_busy.id),
|
||||
extra_fields={},
|
||||
),
|
||||
Request(
|
||||
track_number="TRK-METRICS-2",
|
||||
client_name="Клиент 2",
|
||||
client_phone="+79990000002",
|
||||
topic_code="civil-law",
|
||||
status_code="CLOSED",
|
||||
assigned_lawyer_id=str(lawyer_busy.id),
|
||||
extra_fields={},
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
response = self.client.get("/api/admin/metrics/overview", headers=headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertIn("lawyer_loads", body)
|
||||
self.assertEqual(len(body["lawyer_loads"]), 2)
|
||||
|
||||
by_email = {row["email"]: row for row in body["lawyer_loads"]}
|
||||
self.assertEqual(by_email["busy@example.com"]["active_load"], 1)
|
||||
self.assertEqual(by_email["busy@example.com"]["total_assigned"], 2)
|
||||
self.assertEqual(by_email["busy@example.com"]["avatar_url"], "https://cdn.example.com/a.png")
|
||||
self.assertEqual(by_email["free@example.com"]["active_load"], 0)
|
||||
self.assertEqual(by_email["free@example.com"]["total_assigned"], 0)
|
||||
|
||||
def test_dashboard_metrics_returns_service_request_unread_totals(self):
|
||||
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
lawyer_id = str(uuid4())
|
||||
lawyer_headers = self._auth_headers("LAWYER", sub=lawyer_id, email="lawyer@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
client = Client(full_name="Клиент по запросам", phone="+79990000012", responsible="seed")
|
||||
db.add(client)
|
||||
db.flush()
|
||||
req = Request(
|
||||
track_number="TRK-METRICS-SR-1",
|
||||
client_id=client.id,
|
||||
client_name=client.full_name,
|
||||
client_phone=client.phone,
|
||||
topic_code="consulting",
|
||||
status_code="IN_PROGRESS",
|
||||
assigned_lawyer_id=lawyer_id,
|
||||
extra_fields={},
|
||||
responsible="seed",
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
RequestServiceRequest(
|
||||
request_id=str(req.id),
|
||||
client_id=str(client.id),
|
||||
assigned_lawyer_id=lawyer_id,
|
||||
type="CURATOR_CONTACT",
|
||||
status="NEW",
|
||||
body="Нужна консультация администратора",
|
||||
created_by_client=True,
|
||||
admin_unread=True,
|
||||
lawyer_unread=True,
|
||||
responsible="Клиент",
|
||||
),
|
||||
RequestServiceRequest(
|
||||
request_id=str(req.id),
|
||||
client_id=str(client.id),
|
||||
assigned_lawyer_id=lawyer_id,
|
||||
type="LAWYER_CHANGE_REQUEST",
|
||||
status="NEW",
|
||||
body="Прошу сменить юриста",
|
||||
created_by_client=True,
|
||||
admin_unread=True,
|
||||
lawyer_unread=False,
|
||||
responsible="Клиент",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
admin_response = self.client.get("/api/admin/metrics/overview", headers=admin_headers)
|
||||
self.assertEqual(admin_response.status_code, 200)
|
||||
self.assertEqual(int(admin_response.json().get("service_request_unread_total") or 0), 2)
|
||||
|
||||
lawyer_response = self.client.get("/api/admin/metrics/overview", headers=lawyer_headers)
|
||||
self.assertEqual(lawyer_response.status_code, 200)
|
||||
self.assertEqual(int(lawyer_response.json().get("service_request_unread_total") or 0), 1)
|
||||
|
||||
def test_dashboard_metrics_returns_dynamic_sla_and_frt(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
now = datetime.now(timezone.utc)
|
||||
with self.SessionLocal() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=1, is_terminal=True),
|
||||
]
|
||||
)
|
||||
|
||||
req = Request(
|
||||
track_number="TRK-SLA-M-1",
|
||||
client_name="Клиент SLA",
|
||||
client_phone="+79990000003",
|
||||
topic_code="civil-law",
|
||||
status_code="NEW",
|
||||
extra_fields={},
|
||||
created_at=now - timedelta(hours=30),
|
||||
updated_at=now - timedelta(hours=30),
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
db.add(
|
||||
Message(
|
||||
request_id=req.id,
|
||||
author_type="LAWYER",
|
||||
author_name="Юрист",
|
||||
body="Ответ",
|
||||
created_at=req.created_at + timedelta(minutes=20),
|
||||
updated_at=req.created_at + timedelta(minutes=20),
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
StatusHistory(
|
||||
request_id=req.id,
|
||||
from_status=None,
|
||||
to_status="NEW",
|
||||
changed_by_admin_id=None,
|
||||
created_at=now - timedelta(hours=30),
|
||||
updated_at=now - timedelta(hours=30),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
response = self.client.get("/api/admin/metrics/overview", headers=headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertGreaterEqual(int(body.get("sla_overdue") or 0), 1)
|
||||
self.assertIsNotNone(body.get("frt_avg_minutes"))
|
||||
self.assertAlmostEqual(float(body["frt_avg_minutes"]), 20.0, places=1)
|
||||
self.assertIn("NEW", body.get("avg_time_in_status_hours") or {})
|
||||
|
||||
def test_admin_can_manage_admin_user_topics_only_for_lawyers(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1),
|
||||
Topic(code="tax-law", name="Налоговое право", enabled=True, sort_order=2),
|
||||
]
|
||||
)
|
||||
lawyer = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Профильный",
|
||||
email="lawyer.topics@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
admin = AdminUser(
|
||||
role="ADMIN",
|
||||
name="Администратор",
|
||||
email="admin.topics@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([lawyer, admin])
|
||||
db.commit()
|
||||
lawyer_id = str(lawyer.id)
|
||||
admin_id = str(admin.id)
|
||||
|
||||
created = self.client.post(
|
||||
"/api/admin/crud/admin_user_topics",
|
||||
headers=headers,
|
||||
json={"admin_user_id": lawyer_id, "topic_code": "civil-law"},
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
body = created.json()
|
||||
self.assertEqual(body["admin_user_id"], lawyer_id)
|
||||
self.assertEqual(body["topic_code"], "civil-law")
|
||||
self.assertEqual(body["responsible"], "root@example.com")
|
||||
relation_id = body["id"]
|
||||
UUID(relation_id)
|
||||
|
||||
queried = self.client.post(
|
||||
"/api/admin/crud/admin_user_topics/query",
|
||||
headers=headers,
|
||||
json={
|
||||
"filters": [{"field": "admin_user_id", "op": "=", "value": lawyer_id}],
|
||||
"sort": [{"field": "created_at", "dir": "desc"}],
|
||||
"page": {"limit": 50, "offset": 0},
|
||||
},
|
||||
)
|
||||
self.assertEqual(queried.status_code, 200)
|
||||
self.assertEqual(queried.json()["total"], 1)
|
||||
|
||||
updated = self.client.patch(
|
||||
f"/api/admin/crud/admin_user_topics/{relation_id}",
|
||||
headers=headers,
|
||||
json={"topic_code": "tax-law"},
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200)
|
||||
self.assertEqual(updated.json()["topic_code"], "tax-law")
|
||||
|
||||
forbidden_for_non_lawyer = self.client.post(
|
||||
"/api/admin/crud/admin_user_topics",
|
||||
headers=headers,
|
||||
json={"admin_user_id": admin_id, "topic_code": "civil-law"},
|
||||
)
|
||||
self.assertEqual(forbidden_for_non_lawyer.status_code, 400)
|
||||
|
||||
deleted = self.client.delete(f"/api/admin/crud/admin_user_topics/{relation_id}", headers=headers)
|
||||
self.assertEqual(deleted.status_code, 200)
|
||||
|
||||
def test_topic_templates_crud_and_request_required_fields_validation(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1))
|
||||
db.add(
|
||||
FormField(
|
||||
key="passport_series",
|
||||
label="Серия паспорта",
|
||||
type="string",
|
||||
required=False,
|
||||
enabled=True,
|
||||
sort_order=1,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
required_created = self.client.post(
|
||||
"/api/admin/crud/topic_required_fields",
|
||||
headers=headers,
|
||||
json={
|
||||
"topic_code": "civil-law",
|
||||
"field_key": "passport_series",
|
||||
"required": True,
|
||||
"enabled": True,
|
||||
"sort_order": 10,
|
||||
},
|
||||
)
|
||||
self.assertEqual(required_created.status_code, 201)
|
||||
self.assertEqual(required_created.json()["responsible"], "root@example.com")
|
||||
|
||||
invalid_required = self.client.post(
|
||||
"/api/admin/crud/topic_required_fields",
|
||||
headers=headers,
|
||||
json={
|
||||
"topic_code": "civil-law",
|
||||
"field_key": "missing_field",
|
||||
"required": True,
|
||||
"enabled": True,
|
||||
"sort_order": 11,
|
||||
},
|
||||
)
|
||||
self.assertEqual(invalid_required.status_code, 400)
|
||||
|
||||
template_created = self.client.post(
|
||||
"/api/admin/crud/topic_data_templates",
|
||||
headers=headers,
|
||||
json={
|
||||
"topic_code": "civil-law",
|
||||
"key": "court_file",
|
||||
"label": "Судебный файл",
|
||||
"description": "PDF с материалами",
|
||||
"required": True,
|
||||
"enabled": True,
|
||||
"sort_order": 1,
|
||||
},
|
||||
)
|
||||
self.assertEqual(template_created.status_code, 201)
|
||||
self.assertEqual(template_created.json()["topic_code"], "civil-law")
|
||||
|
||||
blocked = self.client.post(
|
||||
"/api/admin/crud/requests",
|
||||
headers=headers,
|
||||
json={
|
||||
"client_name": "ООО Проверка",
|
||||
"client_phone": "+79995550001",
|
||||
"topic_code": "civil-law",
|
||||
"status_code": "NEW",
|
||||
"description": "missing required extra field",
|
||||
"extra_fields": {},
|
||||
},
|
||||
)
|
||||
self.assertEqual(blocked.status_code, 400)
|
||||
self.assertIn("passport_series", blocked.json().get("detail", ""))
|
||||
|
||||
created = self.client.post(
|
||||
"/api/admin/crud/requests",
|
||||
headers=headers,
|
||||
json={
|
||||
"client_name": "ООО Проверка",
|
||||
"client_phone": "+79995550001",
|
||||
"topic_code": "civil-law",
|
||||
"status_code": "NEW",
|
||||
"description": "required extra field provided",
|
||||
"extra_fields": {"passport_series": "1234"},
|
||||
},
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
request_id = created.json()["id"]
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
row = db.get(Request, UUID(request_id))
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row.extra_fields, {"passport_series": "1234"})
|
||||
|
||||
def test_request_data_template_endpoints_for_assigned_lawyer(self):
|
||||
headers_admin = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1))
|
||||
lawyer = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Шаблон",
|
||||
email="lawyer.template@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
outsider = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Чужой",
|
||||
email="lawyer.outside@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([lawyer, outsider])
|
||||
db.flush()
|
||||
req = Request(
|
||||
track_number="TRK-TEMPLATE-1",
|
||||
client_name="Клиент",
|
||||
client_phone="+79997770013",
|
||||
topic_code="civil-law",
|
||||
status_code="IN_PROGRESS",
|
||||
assigned_lawyer_id=str(lawyer.id),
|
||||
description="template flow",
|
||||
extra_fields={},
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
TopicDataTemplate(
|
||||
topic_code="civil-law",
|
||||
key="power_of_attorney",
|
||||
label="Доверенность",
|
||||
description="Скан доверенности",
|
||||
required=True,
|
||||
enabled=True,
|
||||
sort_order=1,
|
||||
),
|
||||
TopicDataTemplate(
|
||||
topic_code="civil-law",
|
||||
key="claim_copy",
|
||||
label="Копия иска",
|
||||
description="Копия заявления",
|
||||
required=False,
|
||||
enabled=True,
|
||||
sort_order=2,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
request_id = str(req.id)
|
||||
lawyer_id = str(lawyer.id)
|
||||
outsider_id = str(outsider.id)
|
||||
|
||||
headers_lawyer = self._auth_headers("LAWYER", email="lawyer.template@example.com", sub=lawyer_id)
|
||||
headers_outsider = self._auth_headers("LAWYER", email="lawyer.outside@example.com", sub=outsider_id)
|
||||
|
||||
pre = self.client.get(f"/api/admin/requests/{request_id}/data-template", headers=headers_lawyer)
|
||||
self.assertEqual(pre.status_code, 200)
|
||||
self.assertEqual(len(pre.json()["topic_items"]), 2)
|
||||
self.assertEqual(len(pre.json()["request_items"]), 0)
|
||||
|
||||
sync = self.client.post(f"/api/admin/requests/{request_id}/data-template/sync", headers=headers_lawyer)
|
||||
self.assertEqual(sync.status_code, 200)
|
||||
self.assertEqual(sync.json()["created"], 2)
|
||||
|
||||
sync_repeat = self.client.post(f"/api/admin/requests/{request_id}/data-template/sync", headers=headers_lawyer)
|
||||
self.assertEqual(sync_repeat.status_code, 200)
|
||||
self.assertEqual(sync_repeat.json()["created"], 0)
|
||||
|
||||
created_custom = self.client.post(
|
||||
f"/api/admin/requests/{request_id}/data-template/items",
|
||||
headers=headers_lawyer,
|
||||
json={
|
||||
"key": "additional_scan",
|
||||
"label": "Дополнительный скан",
|
||||
"description": "Любой дополнительный файл",
|
||||
"required": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(created_custom.status_code, 201)
|
||||
custom_item_id = created_custom.json()["id"]
|
||||
|
||||
updated_custom = self.client.patch(
|
||||
f"/api/admin/requests/{request_id}/data-template/items/{custom_item_id}",
|
||||
headers=headers_lawyer,
|
||||
json={"label": "Дополнительный скан (обновлено)", "required": True},
|
||||
)
|
||||
self.assertEqual(updated_custom.status_code, 200)
|
||||
self.assertEqual(updated_custom.json()["label"], "Дополнительный скан (обновлено)")
|
||||
self.assertTrue(updated_custom.json()["required"])
|
||||
|
||||
outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/data-template", headers=headers_outsider)
|
||||
self.assertEqual(outsider_forbidden.status_code, 403)
|
||||
|
||||
admin_access = self.client.get(f"/api/admin/requests/{request_id}/data-template", headers=headers_admin)
|
||||
self.assertEqual(admin_access.status_code, 200)
|
||||
self.assertEqual(len(admin_access.json()["request_items"]), 3)
|
||||
|
||||
deleted_custom = self.client.delete(
|
||||
f"/api/admin/requests/{request_id}/data-template/items/{custom_item_id}",
|
||||
headers=headers_lawyer,
|
||||
)
|
||||
self.assertEqual(deleted_custom.status_code, 200)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
count = db.query(RequestDataRequirement).filter(RequestDataRequirement.request_id == UUID(request_id)).count()
|
||||
self.assertEqual(count, 2)
|
||||
286
tests/admin/test_service_requests.py
Normal file
286
tests/admin/test_service_requests.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
from uuid import uuid4
|
||||
|
||||
from tests.admin.base import * # noqa: F401,F403
|
||||
|
||||
|
||||
class AdminServiceRequestsTests(AdminUniversalCrudBase):
|
||||
def test_list_service_requests_respects_role_scope(self):
|
||||
admin_headers = self._auth_headers("ADMIN")
|
||||
lawyer_id = str(uuid4())
|
||||
lawyer_headers = self._auth_headers("LAWYER", sub=lawyer_id, email="lawyer@example.com")
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
client = Client(full_name="Клиент запросов", phone="+79990000010", responsible="seed")
|
||||
db.add(client)
|
||||
db.flush()
|
||||
|
||||
req = Request(
|
||||
track_number="TRK-SREQ-1",
|
||||
client_id=client.id,
|
||||
client_name=client.full_name,
|
||||
client_phone=client.phone,
|
||||
topic_code="consulting",
|
||||
status_code="IN_PROGRESS",
|
||||
description="Проверка запросов клиента",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=lawyer_id,
|
||||
responsible="seed",
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
|
||||
db.add_all(
|
||||
[
|
||||
RequestServiceRequest(
|
||||
request_id=str(req.id),
|
||||
client_id=str(client.id),
|
||||
assigned_lawyer_id=lawyer_id,
|
||||
type="CURATOR_CONTACT",
|
||||
status="NEW",
|
||||
body="Нужна проверка куратора",
|
||||
created_by_client=True,
|
||||
admin_unread=True,
|
||||
lawyer_unread=True,
|
||||
responsible="Клиент",
|
||||
),
|
||||
RequestServiceRequest(
|
||||
request_id=str(req.id),
|
||||
client_id=str(client.id),
|
||||
assigned_lawyer_id=lawyer_id,
|
||||
type="LAWYER_CHANGE_REQUEST",
|
||||
status="NEW",
|
||||
body="Прошу сменить юриста",
|
||||
created_by_client=True,
|
||||
admin_unread=True,
|
||||
lawyer_unread=False,
|
||||
responsible="Клиент",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
request_id = str(req.id)
|
||||
|
||||
listed_admin = self.client.get(f"/api/admin/requests/{request_id}/service-requests", headers=admin_headers)
|
||||
self.assertEqual(listed_admin.status_code, 200)
|
||||
self.assertEqual(listed_admin.json()["total"], 2)
|
||||
|
||||
listed_lawyer = self.client.get(f"/api/admin/requests/{request_id}/service-requests", headers=lawyer_headers)
|
||||
self.assertEqual(listed_lawyer.status_code, 200)
|
||||
self.assertEqual(listed_lawyer.json()["total"], 1)
|
||||
self.assertEqual((listed_lawyer.json()["rows"] or [])[0]["type"], "CURATOR_CONTACT")
|
||||
|
||||
foreign_lawyer = self.client.get(
|
||||
f"/api/admin/requests/{request_id}/service-requests",
|
||||
headers=self._auth_headers("LAWYER", sub=str(uuid4()), email="foreign@example.com"),
|
||||
)
|
||||
self.assertEqual(foreign_lawyer.status_code, 403)
|
||||
|
||||
def test_read_marks_and_status_update_are_audited(self):
|
||||
admin_id = str(uuid4())
|
||||
admin_headers = self._auth_headers("ADMIN", sub=admin_id)
|
||||
lawyer_id = str(uuid4())
|
||||
lawyer_headers = self._auth_headers("LAWYER", sub=lawyer_id, email="lawyer@example.com")
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
client = Client(full_name="Клиент 2", phone="+79990000011", responsible="seed")
|
||||
db.add(client)
|
||||
db.flush()
|
||||
|
||||
req = Request(
|
||||
track_number="TRK-SREQ-2",
|
||||
client_id=client.id,
|
||||
client_name=client.full_name,
|
||||
client_phone=client.phone,
|
||||
topic_code="consulting",
|
||||
status_code="IN_PROGRESS",
|
||||
description="Проверка read/status",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=lawyer_id,
|
||||
responsible="seed",
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
|
||||
curator_row = RequestServiceRequest(
|
||||
request_id=str(req.id),
|
||||
client_id=str(client.id),
|
||||
assigned_lawyer_id=lawyer_id,
|
||||
type="CURATOR_CONTACT",
|
||||
status="NEW",
|
||||
body="Сообщение куратору",
|
||||
created_by_client=True,
|
||||
admin_unread=True,
|
||||
lawyer_unread=True,
|
||||
responsible="Клиент",
|
||||
)
|
||||
change_row = RequestServiceRequest(
|
||||
request_id=str(req.id),
|
||||
client_id=str(client.id),
|
||||
assigned_lawyer_id=lawyer_id,
|
||||
type="LAWYER_CHANGE_REQUEST",
|
||||
status="NEW",
|
||||
body="Нужно сменить юриста",
|
||||
created_by_client=True,
|
||||
admin_unread=True,
|
||||
lawyer_unread=False,
|
||||
responsible="Клиент",
|
||||
)
|
||||
db.add_all([curator_row, change_row])
|
||||
db.commit()
|
||||
curator_id = str(curator_row.id)
|
||||
change_id = str(change_row.id)
|
||||
|
||||
read_lawyer = self.client.post(f"/api/admin/requests/service-requests/{curator_id}/read", headers=lawyer_headers)
|
||||
self.assertEqual(read_lawyer.status_code, 200)
|
||||
self.assertEqual(read_lawyer.json()["changed"], 1)
|
||||
self.assertFalse(read_lawyer.json()["row"]["lawyer_unread"])
|
||||
|
||||
denied_lawyer = self.client.post(f"/api/admin/requests/service-requests/{change_id}/read", headers=lawyer_headers)
|
||||
self.assertEqual(denied_lawyer.status_code, 403)
|
||||
|
||||
read_admin = self.client.post(f"/api/admin/requests/service-requests/{change_id}/read", headers=admin_headers)
|
||||
self.assertEqual(read_admin.status_code, 200)
|
||||
self.assertEqual(read_admin.json()["changed"], 1)
|
||||
self.assertFalse(read_admin.json()["row"]["admin_unread"])
|
||||
|
||||
status_updated = self.client.patch(
|
||||
f"/api/admin/requests/service-requests/{change_id}",
|
||||
headers=admin_headers,
|
||||
json={"status": "RESOLVED"},
|
||||
)
|
||||
self.assertEqual(status_updated.status_code, 200)
|
||||
self.assertEqual(status_updated.json()["changed"], 1)
|
||||
self.assertEqual(status_updated.json()["row"]["status"], "RESOLVED")
|
||||
self.assertEqual(status_updated.json()["row"]["resolved_by_admin_id"], admin_id)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
actions = {
|
||||
row.action
|
||||
for row in db.query(AuditLog)
|
||||
.filter(AuditLog.entity == "request_service_requests", AuditLog.entity_id.in_([curator_id, change_id]))
|
||||
.all()
|
||||
}
|
||||
self.assertIn("READ_MARK_LAWYER", actions)
|
||||
self.assertIn("READ_MARK_ADMIN", actions)
|
||||
self.assertIn("STATUS_UPDATE", actions)
|
||||
|
||||
def test_requests_query_contains_service_request_unread_marker(self):
|
||||
admin_headers = self._auth_headers("ADMIN")
|
||||
lawyer_id = str(uuid4())
|
||||
with self.SessionLocal() as db:
|
||||
client = Client(full_name="Клиент 3", phone="+79990000013", responsible="seed")
|
||||
db.add(client)
|
||||
db.flush()
|
||||
|
||||
req = Request(
|
||||
track_number="TRK-SREQ-3",
|
||||
client_id=client.id,
|
||||
client_name=client.full_name,
|
||||
client_phone=client.phone,
|
||||
topic_code="consulting",
|
||||
status_code="IN_PROGRESS",
|
||||
description="Проверка маркера",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=lawyer_id,
|
||||
responsible="seed",
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
req_id = str(req.id)
|
||||
service_req = RequestServiceRequest(
|
||||
request_id=req_id,
|
||||
client_id=str(client.id),
|
||||
assigned_lawyer_id=lawyer_id,
|
||||
type="CURATOR_CONTACT",
|
||||
status="NEW",
|
||||
body="Нужна проверка",
|
||||
created_by_client=True,
|
||||
admin_unread=True,
|
||||
lawyer_unread=True,
|
||||
responsible="Клиент",
|
||||
)
|
||||
db.add(service_req)
|
||||
db.commit()
|
||||
service_req_id = str(service_req.id)
|
||||
|
||||
queried = self.client.post(
|
||||
"/api/admin/requests/query",
|
||||
headers=admin_headers,
|
||||
json={
|
||||
"filters": [{"field": "track_number", "op": "=", "value": "TRK-SREQ-3"}],
|
||||
"sort": [{"field": "created_at", "dir": "desc"}],
|
||||
"page": {"limit": 10, "offset": 0},
|
||||
},
|
||||
)
|
||||
self.assertEqual(queried.status_code, 200)
|
||||
rows = queried.json()["rows"] or []
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertTrue(rows[0]["has_service_requests_unread"])
|
||||
self.assertEqual(int(rows[0]["service_requests_unread_count"]), 1)
|
||||
|
||||
mark_read = self.client.post(
|
||||
f"/api/admin/requests/service-requests/{service_req_id}/read",
|
||||
headers=admin_headers,
|
||||
)
|
||||
self.assertEqual(mark_read.status_code, 200)
|
||||
|
||||
queried_after = self.client.post(
|
||||
"/api/admin/requests/query",
|
||||
headers=admin_headers,
|
||||
json={
|
||||
"filters": [{"field": "track_number", "op": "=", "value": "TRK-SREQ-3"}],
|
||||
"sort": [{"field": "created_at", "dir": "desc"}],
|
||||
"page": {"limit": 10, "offset": 0},
|
||||
},
|
||||
)
|
||||
self.assertEqual(queried_after.status_code, 200)
|
||||
rows_after = queried_after.json()["rows"] or []
|
||||
self.assertEqual(len(rows_after), 1)
|
||||
self.assertFalse(rows_after[0]["has_service_requests_unread"])
|
||||
self.assertEqual(int(rows_after[0]["service_requests_unread_count"]), 0)
|
||||
|
||||
def test_curator_role_can_view_and_mark_service_requests(self):
|
||||
curator_headers = self._auth_headers("CURATOR", sub=str(uuid4()), email="curator@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
client = Client(full_name="Клиент 4", phone="+79990000014", responsible="seed")
|
||||
db.add(client)
|
||||
db.flush()
|
||||
req = Request(
|
||||
track_number="TRK-SREQ-4",
|
||||
client_id=client.id,
|
||||
client_name=client.full_name,
|
||||
client_phone=client.phone,
|
||||
topic_code="consulting",
|
||||
status_code="IN_PROGRESS",
|
||||
description="Проверка куратора",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=str(uuid4()),
|
||||
responsible="seed",
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
service_req = RequestServiceRequest(
|
||||
request_id=str(req.id),
|
||||
client_id=str(client.id),
|
||||
assigned_lawyer_id=str(req.assigned_lawyer_id),
|
||||
type="LAWYER_CHANGE_REQUEST",
|
||||
status="NEW",
|
||||
body="Прошу сменить юриста",
|
||||
created_by_client=True,
|
||||
admin_unread=True,
|
||||
lawyer_unread=False,
|
||||
responsible="Клиент",
|
||||
)
|
||||
db.add(service_req)
|
||||
db.commit()
|
||||
request_id = str(req.id)
|
||||
service_req_id = str(service_req.id)
|
||||
|
||||
listed = self.client.get(f"/api/admin/requests/{request_id}/service-requests", headers=curator_headers)
|
||||
self.assertEqual(listed.status_code, 200)
|
||||
self.assertEqual(listed.json()["total"], 1)
|
||||
|
||||
mark_read = self.client.post(f"/api/admin/requests/service-requests/{service_req_id}/read", headers=curator_headers)
|
||||
self.assertEqual(mark_read.status_code, 200)
|
||||
self.assertEqual(mark_read.json()["changed"], 1)
|
||||
self.assertFalse(mark_read.json()["row"]["admin_unread"])
|
||||
762
tests/admin/test_status_flow_kanban.py
Normal file
762
tests/admin/test_status_flow_kanban.py
Normal file
|
|
@ -0,0 +1,762 @@
|
|||
from tests.admin.base import * # noqa: F401,F403
|
||||
|
||||
|
||||
class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
|
||||
def test_request_read_markers_status_update_and_lawyer_open_reset(self):
|
||||
with self.SessionLocal() as db:
|
||||
lawyer = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист Маркер",
|
||||
email="lawyer-marker@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(lawyer)
|
||||
db.flush()
|
||||
request_row = Request(
|
||||
track_number="TRK-MARK-1",
|
||||
client_name="Клиент Маркер",
|
||||
client_phone="+79990009900",
|
||||
status_code="NEW",
|
||||
description="markers",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=str(lawyer.id),
|
||||
lawyer_has_unread_updates=True,
|
||||
lawyer_unread_event_type="MESSAGE",
|
||||
)
|
||||
db.add(request_row)
|
||||
db.commit()
|
||||
lawyer_id = str(lawyer.id)
|
||||
request_id = str(request_row.id)
|
||||
|
||||
lawyer_headers = self._auth_headers("LAWYER", email="lawyer-marker@example.com", sub=lawyer_id)
|
||||
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
|
||||
opened = self.client.get(f"/api/admin/crud/requests/{request_id}", headers=lawyer_headers)
|
||||
self.assertEqual(opened.status_code, 200)
|
||||
opened_body = opened.json()
|
||||
self.assertFalse(opened_body["lawyer_has_unread_updates"])
|
||||
self.assertIsNone(opened_body["lawyer_unread_event_type"])
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
opened_db = db.get(Request, UUID(request_id))
|
||||
self.assertIsNotNone(opened_db)
|
||||
self.assertFalse(opened_db.lawyer_has_unread_updates)
|
||||
self.assertIsNone(opened_db.lawyer_unread_event_type)
|
||||
|
||||
updated = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=admin_headers,
|
||||
json={"status_code": "IN_PROGRESS"},
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200)
|
||||
updated_body = updated.json()
|
||||
self.assertTrue(updated_body["client_has_unread_updates"])
|
||||
self.assertEqual(updated_body["client_unread_event_type"], "STATUS")
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
refreshed = db.get(Request, UUID(request_id))
|
||||
self.assertIsNotNone(refreshed)
|
||||
self.assertEqual(refreshed.status_code, "IN_PROGRESS")
|
||||
self.assertTrue(refreshed.client_has_unread_updates)
|
||||
self.assertEqual(refreshed.client_unread_event_type, "STATUS")
|
||||
|
||||
def test_topic_status_flow_blocks_disallowed_transitions(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1))
|
||||
db.add_all(
|
||||
[
|
||||
TopicStatusTransition(topic_code="civil-law", from_status="NEW", to_status="IN_PROGRESS", enabled=True, sort_order=1),
|
||||
TopicStatusTransition(
|
||||
topic_code="civil-law",
|
||||
from_status="IN_PROGRESS",
|
||||
to_status="WAITING_CLIENT",
|
||||
enabled=True,
|
||||
sort_order=2,
|
||||
),
|
||||
]
|
||||
)
|
||||
req = Request(
|
||||
track_number="TRK-FLOW-1",
|
||||
client_name="Клиент Флоу",
|
||||
client_phone="+79997770011",
|
||||
topic_code="civil-law",
|
||||
status_code="NEW",
|
||||
description="flow",
|
||||
extra_fields={},
|
||||
)
|
||||
db.add(req)
|
||||
db.commit()
|
||||
request_id = str(req.id)
|
||||
|
||||
allowed = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "IN_PROGRESS"},
|
||||
)
|
||||
self.assertEqual(allowed.status_code, 200)
|
||||
|
||||
blocked = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "CLOSED"},
|
||||
)
|
||||
self.assertEqual(blocked.status_code, 400)
|
||||
self.assertIn("Переход статуса не разрешен", blocked.json().get("detail", ""))
|
||||
|
||||
blocked_legacy = self.client.patch(
|
||||
f"/api/admin/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "CLOSED"},
|
||||
)
|
||||
self.assertEqual(blocked_legacy.status_code, 400)
|
||||
self.assertIn("Переход статуса не разрешен", blocked_legacy.json().get("detail", ""))
|
||||
|
||||
def test_topic_without_configured_flow_keeps_backward_compatibility(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
db.add(Topic(code="tax-law", name="Налоговое право", enabled=True, sort_order=1))
|
||||
req = Request(
|
||||
track_number="TRK-FLOW-2",
|
||||
client_name="Клиент Флоу 2",
|
||||
client_phone="+79997770012",
|
||||
topic_code="tax-law",
|
||||
status_code="NEW",
|
||||
description="flow fallback",
|
||||
extra_fields={},
|
||||
)
|
||||
db.add(req)
|
||||
db.commit()
|
||||
request_id = str(req.id)
|
||||
|
||||
updated = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "CLOSED"},
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200)
|
||||
|
||||
def test_admin_can_configure_sla_hours_for_status_transition(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1))
|
||||
db.add_all(
|
||||
[
|
||||
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
created = self.client.post(
|
||||
"/api/admin/crud/topic_status_transitions",
|
||||
headers=headers,
|
||||
json={
|
||||
"topic_code": "civil-law",
|
||||
"from_status": "NEW",
|
||||
"to_status": "IN_PROGRESS",
|
||||
"enabled": True,
|
||||
"sort_order": 1,
|
||||
"sla_hours": 24,
|
||||
},
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
body = created.json()
|
||||
self.assertEqual(body["sla_hours"], 24)
|
||||
row_id = body["id"]
|
||||
|
||||
updated = self.client.patch(
|
||||
f"/api/admin/crud/topic_status_transitions/{row_id}",
|
||||
headers=headers,
|
||||
json={"sla_hours": 12},
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200)
|
||||
self.assertEqual(updated.json()["sla_hours"], 12)
|
||||
|
||||
invalid_zero = self.client.patch(
|
||||
f"/api/admin/crud/topic_status_transitions/{row_id}",
|
||||
headers=headers,
|
||||
json={"sla_hours": 0},
|
||||
)
|
||||
self.assertEqual(invalid_zero.status_code, 400)
|
||||
|
||||
invalid_same_status = self.client.patch(
|
||||
f"/api/admin/crud/topic_status_transitions/{row_id}",
|
||||
headers=headers,
|
||||
json={"to_status": "NEW"},
|
||||
)
|
||||
self.assertEqual(invalid_same_status.status_code, 400)
|
||||
|
||||
def test_admin_can_configure_transition_step_requirements(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
db.add(Topic(code="civil-designer", name="Гражданское (конструктор)", enabled=True, sort_order=1))
|
||||
db.add_all(
|
||||
[
|
||||
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
created = self.client.post(
|
||||
"/api/admin/crud/topic_status_transitions",
|
||||
headers=headers,
|
||||
json={
|
||||
"topic_code": "civil-designer",
|
||||
"from_status": "NEW",
|
||||
"to_status": "IN_PROGRESS",
|
||||
"enabled": True,
|
||||
"sort_order": 1,
|
||||
"sla_hours": 24,
|
||||
"required_data_keys": ["passport_scan", "client_address"],
|
||||
"required_mime_types": ["application/pdf", "image/*"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
body = created.json()
|
||||
self.assertEqual(body["required_data_keys"], ["passport_scan", "client_address"])
|
||||
self.assertEqual(body["required_mime_types"], ["application/pdf", "image/*"])
|
||||
|
||||
row_id = body["id"]
|
||||
updated = self.client.patch(
|
||||
f"/api/admin/crud/topic_status_transitions/{row_id}",
|
||||
headers=headers,
|
||||
json={
|
||||
"required_data_keys": ["passport_scan"],
|
||||
"required_mime_types": [],
|
||||
},
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200)
|
||||
self.assertEqual(updated.json()["required_data_keys"], ["passport_scan"])
|
||||
self.assertEqual(updated.json()["required_mime_types"], [])
|
||||
|
||||
def test_request_status_transition_requires_step_data_and_files(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
db.add(Topic(code="civil-step-check", name="Проверка шага", enabled=True, sort_order=1))
|
||||
db.add_all(
|
||||
[
|
||||
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
|
||||
]
|
||||
)
|
||||
db.add(
|
||||
TopicStatusTransition(
|
||||
topic_code="civil-step-check",
|
||||
from_status="NEW",
|
||||
to_status="IN_PROGRESS",
|
||||
enabled=True,
|
||||
sort_order=1,
|
||||
sla_hours=48,
|
||||
required_data_keys=["passport_scan"],
|
||||
required_mime_types=["application/pdf"],
|
||||
)
|
||||
)
|
||||
req = Request(
|
||||
track_number="TRK-STEP-REQ-1",
|
||||
client_name="Клиент шага",
|
||||
client_phone="+79990042211",
|
||||
topic_code="civil-step-check",
|
||||
status_code="NEW",
|
||||
description="step requirements",
|
||||
extra_fields={},
|
||||
)
|
||||
db.add(req)
|
||||
db.commit()
|
||||
request_id = str(req.id)
|
||||
request_uuid = UUID(request_id)
|
||||
|
||||
blocked_without_all = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "IN_PROGRESS"},
|
||||
)
|
||||
self.assertEqual(blocked_without_all.status_code, 400)
|
||||
self.assertIn("обязательные данные", blocked_without_all.json().get("detail", ""))
|
||||
self.assertIn("обязательные файлы", blocked_without_all.json().get("detail", ""))
|
||||
|
||||
blocked_without_all_legacy = self.client.patch(
|
||||
f"/api/admin/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "IN_PROGRESS"},
|
||||
)
|
||||
self.assertEqual(blocked_without_all_legacy.status_code, 400)
|
||||
self.assertIn("обязательные данные", blocked_without_all_legacy.json().get("detail", ""))
|
||||
|
||||
with_data_only = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"extra_fields": {"passport_scan": "добавлено"}},
|
||||
)
|
||||
self.assertEqual(with_data_only.status_code, 200)
|
||||
|
||||
blocked_without_file = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "IN_PROGRESS"},
|
||||
)
|
||||
self.assertEqual(blocked_without_file.status_code, 400)
|
||||
self.assertIn("обязательные файлы", blocked_without_file.json().get("detail", ""))
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
db.add(
|
||||
Attachment(
|
||||
request_id=request_uuid,
|
||||
file_name="passport.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=1024,
|
||||
s3_key="requests/passport.pdf",
|
||||
immutable=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
moved = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "IN_PROGRESS"},
|
||||
)
|
||||
self.assertEqual(moved.status_code, 200)
|
||||
self.assertEqual(moved.json().get("status_code"), "IN_PROGRESS")
|
||||
|
||||
def test_status_change_freezes_previous_messages_and_attachments_and_writes_history(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
req = Request(
|
||||
track_number="TRK-IMM-1",
|
||||
client_name="Клиент Иммутабельность",
|
||||
client_phone="+79998880011",
|
||||
topic_code="civil-law",
|
||||
status_code="NEW",
|
||||
description="immutable",
|
||||
extra_fields={},
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
msg = Message(
|
||||
request_id=req.id,
|
||||
author_type="CLIENT",
|
||||
author_name="Клиент",
|
||||
body="Первое сообщение",
|
||||
immutable=False,
|
||||
)
|
||||
att = Attachment(
|
||||
request_id=req.id,
|
||||
file_name="old.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=100,
|
||||
s3_key="requests/old.pdf",
|
||||
immutable=False,
|
||||
)
|
||||
db.add_all([msg, att])
|
||||
db.commit()
|
||||
request_id = str(req.id)
|
||||
message_id = str(msg.id)
|
||||
attachment_id = str(att.id)
|
||||
|
||||
changed = self.client.patch(
|
||||
f"/api/admin/crud/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "IN_PROGRESS"},
|
||||
)
|
||||
self.assertEqual(changed.status_code, 200)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
msg = db.get(Message, UUID(message_id))
|
||||
att = db.get(Attachment, UUID(attachment_id))
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertIsNotNone(att)
|
||||
self.assertTrue(msg.immutable)
|
||||
self.assertTrue(att.immutable)
|
||||
history = db.query(StatusHistory).filter(StatusHistory.request_id == UUID(request_id)).all()
|
||||
self.assertEqual(len(history), 1)
|
||||
self.assertEqual(history[0].from_status, "NEW")
|
||||
self.assertEqual(history[0].to_status, "IN_PROGRESS")
|
||||
|
||||
blocked_update = self.client.patch(
|
||||
f"/api/admin/crud/messages/{message_id}",
|
||||
headers=headers,
|
||||
json={"body": "Попытка правки"},
|
||||
)
|
||||
self.assertEqual(blocked_update.status_code, 400)
|
||||
self.assertIn("зафиксирована", blocked_update.json().get("detail", ""))
|
||||
|
||||
blocked_delete = self.client.delete(f"/api/admin/crud/attachments/{attachment_id}", headers=headers)
|
||||
self.assertEqual(blocked_delete.status_code, 400)
|
||||
self.assertIn("зафиксирована", blocked_delete.json().get("detail", ""))
|
||||
|
||||
def test_legacy_request_patch_also_writes_status_history_and_freezes(self):
|
||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
with self.SessionLocal() as db:
|
||||
req = Request(
|
||||
track_number="TRK-IMM-2",
|
||||
client_name="Клиент Legacy",
|
||||
client_phone="+79998880012",
|
||||
topic_code="civil-law",
|
||||
status_code="NEW",
|
||||
description="legacy immutable",
|
||||
extra_fields={},
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
msg = Message(
|
||||
request_id=req.id,
|
||||
author_type="LAWYER",
|
||||
author_name="Юрист",
|
||||
body="Ответ",
|
||||
immutable=False,
|
||||
)
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
request_id = str(req.id)
|
||||
message_id = str(msg.id)
|
||||
|
||||
changed = self.client.patch(
|
||||
f"/api/admin/requests/{request_id}",
|
||||
headers=headers,
|
||||
json={"status_code": "IN_PROGRESS"},
|
||||
)
|
||||
self.assertEqual(changed.status_code, 200)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
msg = db.get(Message, UUID(message_id))
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertTrue(msg.immutable)
|
||||
history = db.query(StatusHistory).filter(StatusHistory.request_id == UUID(request_id)).all()
|
||||
self.assertEqual(len(history), 1)
|
||||
self.assertEqual(history[0].from_status, "NEW")
|
||||
self.assertEqual(history[0].to_status, "IN_PROGRESS")
|
||||
|
||||
def test_request_status_route_returns_progress_and_respects_role_scope(self):
|
||||
with self.SessionLocal() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Status(code="NEW", name="Новая", enabled=True, sort_order=1, kind="DEFAULT"),
|
||||
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=2, kind="DEFAULT"),
|
||||
Status(code="WAITING_CLIENT", name="Ожидание клиента", enabled=True, sort_order=3, kind="DEFAULT"),
|
||||
]
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
TopicStatusTransition(
|
||||
topic_code="civil-law",
|
||||
from_status="NEW",
|
||||
to_status="IN_PROGRESS",
|
||||
enabled=True,
|
||||
sla_hours=24,
|
||||
sort_order=1,
|
||||
),
|
||||
TopicStatusTransition(
|
||||
topic_code="civil-law",
|
||||
from_status="IN_PROGRESS",
|
||||
to_status="WAITING_CLIENT",
|
||||
enabled=True,
|
||||
sla_hours=72,
|
||||
sort_order=2,
|
||||
),
|
||||
]
|
||||
)
|
||||
lawyer = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист маршрута",
|
||||
email="lawyer.route@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
outsider = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Чужой юрист",
|
||||
email="lawyer.outside.route@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([lawyer, outsider])
|
||||
db.flush()
|
||||
req = Request(
|
||||
track_number="TRK-ROUTE-1",
|
||||
client_name="Клиент",
|
||||
client_phone="+79990001122",
|
||||
topic_code="civil-law",
|
||||
status_code="IN_PROGRESS",
|
||||
assigned_lawyer_id=str(lawyer.id),
|
||||
description="route check",
|
||||
extra_fields={},
|
||||
)
|
||||
db.add(req)
|
||||
db.flush()
|
||||
db.add(
|
||||
StatusHistory(
|
||||
request_id=req.id,
|
||||
from_status="NEW",
|
||||
to_status="IN_PROGRESS",
|
||||
comment="start progress",
|
||||
changed_by_admin_id=None,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
request_id = str(req.id)
|
||||
lawyer_id = str(lawyer.id)
|
||||
outsider_id = str(outsider.id)
|
||||
|
||||
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
assigned_headers = self._auth_headers("LAWYER", email="lawyer.route@example.com", sub=lawyer_id)
|
||||
outsider_headers = self._auth_headers("LAWYER", email="lawyer.outside.route@example.com", sub=outsider_id)
|
||||
|
||||
admin_response = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=admin_headers)
|
||||
self.assertEqual(admin_response.status_code, 200)
|
||||
payload = admin_response.json()
|
||||
self.assertEqual(payload["current_status"], "IN_PROGRESS")
|
||||
nodes = payload.get("nodes") or []
|
||||
self.assertEqual([item["code"] for item in nodes], ["NEW", "IN_PROGRESS", "WAITING_CLIENT"])
|
||||
self.assertEqual(nodes[0]["state"], "completed")
|
||||
self.assertEqual(nodes[1]["state"], "current")
|
||||
self.assertEqual(nodes[2]["state"], "pending")
|
||||
self.assertEqual(nodes[1]["sla_hours"], 24)
|
||||
self.assertEqual(nodes[2]["sla_hours"], 72)
|
||||
|
||||
assigned_response = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=assigned_headers)
|
||||
self.assertEqual(assigned_response.status_code, 200)
|
||||
self.assertEqual(assigned_response.json()["current_status"], "IN_PROGRESS")
|
||||
|
||||
outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=outsider_headers)
|
||||
self.assertEqual(outsider_forbidden.status_code, 403)
|
||||
|
||||
def test_requests_kanban_returns_grouped_cards_and_role_scope(self):
|
||||
with self.SessionLocal() as db:
|
||||
group_new = StatusGroup(name="Новые", sort_order=10)
|
||||
group_progress = StatusGroup(name="В работе", sort_order=20)
|
||||
group_waiting = StatusGroup(name="Ожидание", sort_order=30)
|
||||
group_done = StatusGroup(name="Завершены", sort_order=40)
|
||||
db.add_all([group_new, group_progress, group_waiting, group_done])
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
Status(
|
||||
code="NEW",
|
||||
name="Новая",
|
||||
enabled=True,
|
||||
sort_order=1,
|
||||
is_terminal=False,
|
||||
kind="DEFAULT",
|
||||
status_group_id=group_new.id,
|
||||
),
|
||||
Status(
|
||||
code="IN_PROGRESS",
|
||||
name="В работе",
|
||||
enabled=True,
|
||||
sort_order=2,
|
||||
is_terminal=False,
|
||||
kind="DEFAULT",
|
||||
status_group_id=group_progress.id,
|
||||
),
|
||||
Status(
|
||||
code="WAITING_CLIENT",
|
||||
name="Ожидание клиента",
|
||||
enabled=True,
|
||||
sort_order=3,
|
||||
is_terminal=False,
|
||||
kind="DEFAULT",
|
||||
status_group_id=group_waiting.id,
|
||||
),
|
||||
Status(
|
||||
code="CLOSED",
|
||||
name="Закрыта",
|
||||
enabled=True,
|
||||
sort_order=4,
|
||||
is_terminal=True,
|
||||
kind="DEFAULT",
|
||||
status_group_id=group_done.id,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1))
|
||||
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=12,
|
||||
sort_order=2,
|
||||
),
|
||||
TopicStatusTransition(
|
||||
topic_code="civil-law",
|
||||
from_status="WAITING_CLIENT",
|
||||
to_status="CLOSED",
|
||||
enabled=True,
|
||||
sla_hours=8,
|
||||
sort_order=3,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
lawyer_main = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Юрист канбана",
|
||||
email="lawyer.kanban@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
lawyer_other = AdminUser(
|
||||
role="LAWYER",
|
||||
name="Другой юрист",
|
||||
email="lawyer.kanban.other@example.com",
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([lawyer_main, lawyer_other])
|
||||
db.flush()
|
||||
|
||||
request_new = Request(
|
||||
track_number="TRK-KANBAN-NEW",
|
||||
client_name="Клиент 1",
|
||||
client_phone="+79990000001",
|
||||
topic_code="civil-law",
|
||||
status_code="NEW",
|
||||
description="Новая неназначенная",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=None,
|
||||
)
|
||||
request_progress = Request(
|
||||
track_number="TRK-KANBAN-PROGRESS",
|
||||
client_name="Клиент 2",
|
||||
client_phone="+79990000002",
|
||||
topic_code="civil-law",
|
||||
status_code="IN_PROGRESS",
|
||||
description="Заявка в работе",
|
||||
extra_fields={"deadline_at": "2031-01-01T10:00:00+00:00"},
|
||||
assigned_lawyer_id=str(lawyer_main.id),
|
||||
)
|
||||
request_waiting = Request(
|
||||
track_number="TRK-KANBAN-WAITING",
|
||||
client_name="Клиент 3",
|
||||
client_phone="+79990000003",
|
||||
topic_code="civil-law",
|
||||
status_code="WAITING_CLIENT",
|
||||
description="Чужая заявка",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=str(lawyer_other.id),
|
||||
)
|
||||
request_overdue = Request(
|
||||
track_number="TRK-KANBAN-OVERDUE",
|
||||
client_name="Клиент 4",
|
||||
client_phone="+79990000004",
|
||||
topic_code="civil-law",
|
||||
status_code="IN_PROGRESS",
|
||||
description="Просроченная заявка",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=str(lawyer_main.id),
|
||||
)
|
||||
db.add_all([request_new, request_progress, request_waiting, request_overdue])
|
||||
db.flush()
|
||||
|
||||
entered_progress_at = datetime.now(timezone.utc) - timedelta(hours=2)
|
||||
entered_overdue_at = datetime.now(timezone.utc) - timedelta(hours=30)
|
||||
db.add(
|
||||
StatusHistory(
|
||||
request_id=request_progress.id,
|
||||
from_status="NEW",
|
||||
to_status="IN_PROGRESS",
|
||||
changed_by_admin_id=None,
|
||||
comment="started",
|
||||
created_at=entered_progress_at,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
StatusHistory(
|
||||
request_id=request_overdue.id,
|
||||
from_status="NEW",
|
||||
to_status="IN_PROGRESS",
|
||||
changed_by_admin_id=None,
|
||||
comment="overdue",
|
||||
created_at=entered_overdue_at,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
request_new_id = str(request_new.id)
|
||||
request_progress_id = str(request_progress.id)
|
||||
request_waiting_id = str(request_waiting.id)
|
||||
request_overdue_id = str(request_overdue.id)
|
||||
lawyer_main_id = str(lawyer_main.id)
|
||||
group_new_id = str(group_new.id)
|
||||
group_progress_id = str(group_progress.id)
|
||||
|
||||
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||
admin_response = self.client.get("/api/admin/requests/kanban?limit=100", headers=admin_headers)
|
||||
self.assertEqual(admin_response.status_code, 200)
|
||||
admin_payload = admin_response.json()
|
||||
self.assertEqual(admin_payload["scope"], "ADMIN")
|
||||
self.assertEqual(admin_payload["total"], 4)
|
||||
rows = {item["id"]: item for item in (admin_payload.get("rows") or [])}
|
||||
self.assertIn(request_new_id, rows)
|
||||
self.assertIn(request_progress_id, rows)
|
||||
self.assertIn(request_waiting_id, rows)
|
||||
self.assertIn(request_overdue_id, rows)
|
||||
self.assertEqual(rows[request_new_id]["status_group"], group_new_id)
|
||||
self.assertEqual(rows[request_progress_id]["status_group"], group_progress_id)
|
||||
self.assertEqual(rows[request_progress_id]["assigned_lawyer_id"], lawyer_main_id)
|
||||
transitions = rows[request_progress_id].get("available_transitions") or []
|
||||
self.assertTrue(any(item.get("to_status") == "WAITING_CLIENT" for item in transitions))
|
||||
self.assertEqual(rows[request_progress_id]["case_deadline_at"], "2031-01-01T10:00:00+00:00")
|
||||
self.assertIsNotNone(rows[request_progress_id]["sla_deadline_at"])
|
||||
self.assertFalse(bool(admin_payload.get("truncated")))
|
||||
self.assertEqual([item.get("label") for item in (admin_payload.get("columns") or [])][:4], ["Новые", "В работе", "Ожидание", "Завершены"])
|
||||
|
||||
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.kanban@example.com", sub=lawyer_main_id)
|
||||
lawyer_response = self.client.get("/api/admin/requests/kanban?limit=100", headers=lawyer_headers)
|
||||
self.assertEqual(lawyer_response.status_code, 200)
|
||||
lawyer_payload = lawyer_response.json()
|
||||
self.assertEqual(lawyer_payload["scope"], "LAWYER")
|
||||
lawyer_rows = {item["id"]: item for item in (lawyer_payload.get("rows") or [])}
|
||||
self.assertIn(request_new_id, lawyer_rows)
|
||||
self.assertIn(request_progress_id, lawyer_rows)
|
||||
self.assertIn(request_overdue_id, lawyer_rows)
|
||||
self.assertNotIn(request_waiting_id, lawyer_rows)
|
||||
self.assertEqual(lawyer_payload["total"], 3)
|
||||
|
||||
filtered_by_lawyer = self.client.get(
|
||||
"/api/admin/requests/kanban",
|
||||
headers=admin_headers,
|
||||
params={
|
||||
"limit": 100,
|
||||
"filters": json.dumps([{"field": "assigned_lawyer_id", "op": "=", "value": lawyer_main_id}]),
|
||||
},
|
||||
)
|
||||
self.assertEqual(filtered_by_lawyer.status_code, 200)
|
||||
filtered_rows = {item["id"] for item in (filtered_by_lawyer.json().get("rows") or [])}
|
||||
self.assertEqual(filtered_rows, {request_progress_id, request_overdue_id})
|
||||
|
||||
filtered_overdue = self.client.get(
|
||||
"/api/admin/requests/kanban",
|
||||
headers=admin_headers,
|
||||
params={
|
||||
"limit": 100,
|
||||
"filters": json.dumps([{"field": "overdue", "op": "=", "value": True}]),
|
||||
},
|
||||
)
|
||||
self.assertEqual(filtered_overdue.status_code, 200)
|
||||
overdue_rows = {item["id"] for item in (filtered_overdue.json().get("rows") or [])}
|
||||
self.assertEqual(overdue_rows, {request_overdue_id})
|
||||
|
||||
sorted_by_deadline = self.client.get(
|
||||
"/api/admin/requests/kanban",
|
||||
headers=admin_headers,
|
||||
params={"limit": 100, "sort_mode": "deadline"},
|
||||
)
|
||||
self.assertEqual(sorted_by_deadline.status_code, 200)
|
||||
sorted_rows = sorted_by_deadline.json().get("rows") or []
|
||||
self.assertTrue(sorted_rows)
|
||||
self.assertEqual(sorted_rows[0]["id"], request_overdue_id)
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -23,6 +23,7 @@ from app.models.admin_user import AdminUser
|
|||
from app.models.audit_log import AuditLog
|
||||
from app.models.message import Message
|
||||
from app.models.request import Request
|
||||
from app.models.request_service_request import RequestServiceRequest
|
||||
from app.models.status import Status
|
||||
from app.models.status_history import StatusHistory
|
||||
from app.models.topic_status_transition import TopicStatusTransition
|
||||
|
|
@ -42,6 +43,7 @@ class DashboardFinanceTests(unittest.TestCase):
|
|||
Request.__table__.create(bind=cls.engine)
|
||||
Status.__table__.create(bind=cls.engine)
|
||||
Message.__table__.create(bind=cls.engine)
|
||||
RequestServiceRequest.__table__.create(bind=cls.engine)
|
||||
StatusHistory.__table__.create(bind=cls.engine)
|
||||
TopicStatusTransition.__table__.create(bind=cls.engine)
|
||||
|
||||
|
|
@ -49,6 +51,7 @@ class DashboardFinanceTests(unittest.TestCase):
|
|||
def tearDownClass(cls):
|
||||
StatusHistory.__table__.drop(bind=cls.engine)
|
||||
TopicStatusTransition.__table__.drop(bind=cls.engine)
|
||||
RequestServiceRequest.__table__.drop(bind=cls.engine)
|
||||
Message.__table__.drop(bind=cls.engine)
|
||||
Status.__table__.drop(bind=cls.engine)
|
||||
Request.__table__.drop(bind=cls.engine)
|
||||
|
|
@ -61,6 +64,7 @@ class DashboardFinanceTests(unittest.TestCase):
|
|||
db.execute(delete(StatusHistory))
|
||||
db.execute(delete(TopicStatusTransition))
|
||||
db.execute(delete(Message))
|
||||
db.execute(delete(RequestServiceRequest))
|
||||
db.execute(delete(Request))
|
||||
db.execute(delete(Status))
|
||||
db.execute(delete(AuditLog))
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ class MigrationTests(unittest.TestCase):
|
|||
"request_data_templates",
|
||||
"request_data_template_items",
|
||||
"request_data_requirements",
|
||||
"request_service_requests",
|
||||
"requests",
|
||||
"messages",
|
||||
"attachments",
|
||||
|
|
@ -112,7 +113,7 @@ class MigrationTests(unittest.TestCase):
|
|||
def test_alembic_version_is_set(self):
|
||||
with self.engine.connect() as conn:
|
||||
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
||||
self.assertEqual(version, "0024_featured_staff_carousel")
|
||||
self.assertEqual(version, "0026_srv_req_str_ids")
|
||||
|
||||
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||
tables = {
|
||||
|
|
@ -128,6 +129,7 @@ class MigrationTests(unittest.TestCase):
|
|||
"request_data_templates",
|
||||
"request_data_template_items",
|
||||
"request_data_requirements",
|
||||
"request_service_requests",
|
||||
"requests",
|
||||
"messages",
|
||||
"attachments",
|
||||
|
|
@ -263,6 +265,19 @@ class MigrationTests(unittest.TestCase):
|
|||
self.assertIn("value_type", items)
|
||||
self.assertIn("sort_order", items)
|
||||
|
||||
def test_request_service_requests_contains_core_columns(self):
|
||||
columns = {column["name"] for column in self.inspector.get_columns("request_service_requests")}
|
||||
self.assertIn("request_id", columns)
|
||||
self.assertIn("client_id", columns)
|
||||
self.assertIn("assigned_lawyer_id", columns)
|
||||
self.assertIn("type", columns)
|
||||
self.assertIn("status", columns)
|
||||
self.assertIn("body", columns)
|
||||
self.assertIn("admin_unread", columns)
|
||||
self.assertIn("lawyer_unread", columns)
|
||||
self.assertIn("admin_read_at", columns)
|
||||
self.assertIn("lawyer_read_at", columns)
|
||||
|
||||
def test_landing_featured_staff_contains_core_columns(self):
|
||||
columns = {column["name"] for column in self.inspector.get_columns("landing_featured_staff")}
|
||||
self.assertIn("admin_user_id", columns)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from app.models.attachment import Attachment
|
|||
from app.models.message import Message
|
||||
from app.models.notification import Notification
|
||||
from app.models.request import Request
|
||||
from app.models.request_data_requirement import RequestDataRequirement
|
||||
from app.models.status_history import StatusHistory
|
||||
|
||||
|
||||
|
|
@ -61,10 +62,12 @@ class PublicCabinetTests(unittest.TestCase):
|
|||
Notification.__table__.create(bind=cls.engine)
|
||||
Message.__table__.create(bind=cls.engine)
|
||||
Attachment.__table__.create(bind=cls.engine)
|
||||
RequestDataRequirement.__table__.create(bind=cls.engine)
|
||||
StatusHistory.__table__.create(bind=cls.engine)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
RequestDataRequirement.__table__.drop(bind=cls.engine)
|
||||
StatusHistory.__table__.drop(bind=cls.engine)
|
||||
Attachment.__table__.drop(bind=cls.engine)
|
||||
Message.__table__.drop(bind=cls.engine)
|
||||
|
|
@ -77,6 +80,7 @@ class PublicCabinetTests(unittest.TestCase):
|
|||
db.execute(delete(Notification))
|
||||
db.execute(delete(StatusHistory))
|
||||
db.execute(delete(Attachment))
|
||||
db.execute(delete(RequestDataRequirement))
|
||||
db.execute(delete(Message))
|
||||
db.execute(delete(Request))
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import os
|
|||
import unittest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, delete
|
||||
|
|
@ -22,9 +22,11 @@ from app.core.config import settings
|
|||
from app.core.security import create_jwt, decode_jwt
|
||||
from app.db.session import get_db
|
||||
from app.models.client import Client
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.otp_session import OtpSession
|
||||
from app.models.request import Request
|
||||
from app.models.request_service_request import RequestServiceRequest
|
||||
from app.models.topic_required_field import TopicRequiredField
|
||||
|
||||
|
||||
|
|
@ -38,26 +40,32 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
)
|
||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||
Client.__table__.create(bind=cls.engine)
|
||||
AuditLog.__table__.create(bind=cls.engine)
|
||||
Request.__table__.create(bind=cls.engine)
|
||||
RequestServiceRequest.__table__.create(bind=cls.engine)
|
||||
Notification.__table__.create(bind=cls.engine)
|
||||
OtpSession.__table__.create(bind=cls.engine)
|
||||
TopicRequiredField.__table__.create(bind=cls.engine)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
RequestServiceRequest.__table__.drop(bind=cls.engine)
|
||||
Notification.__table__.drop(bind=cls.engine)
|
||||
OtpSession.__table__.drop(bind=cls.engine)
|
||||
TopicRequiredField.__table__.drop(bind=cls.engine)
|
||||
Request.__table__.drop(bind=cls.engine)
|
||||
AuditLog.__table__.drop(bind=cls.engine)
|
||||
Client.__table__.drop(bind=cls.engine)
|
||||
cls.engine.dispose()
|
||||
|
||||
def setUp(self):
|
||||
with self.SessionLocal() as db:
|
||||
db.execute(delete(RequestServiceRequest))
|
||||
db.execute(delete(Notification))
|
||||
db.execute(delete(OtpSession))
|
||||
db.execute(delete(TopicRequiredField))
|
||||
db.execute(delete(Request))
|
||||
db.execute(delete(AuditLog))
|
||||
db.execute(delete(Client))
|
||||
db.commit()
|
||||
|
||||
|
|
@ -75,6 +83,11 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
self.client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@staticmethod
|
||||
def _unique_phone() -> str:
|
||||
suffix = f"{uuid4().int % 10_000_000_000:010d}"
|
||||
return f"+79{suffix}"
|
||||
|
||||
def _send_and_verify_create_otp(self, phone: str) -> None:
|
||||
with patch("app.api.public.otp._generate_code", return_value="123456"):
|
||||
sent = self.client.post(
|
||||
|
|
@ -135,11 +148,12 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
self.assertEqual(read.json()["track_number"], body["track_number"])
|
||||
|
||||
def test_view_request_requires_view_otp_and_uses_track_cookie(self):
|
||||
track_number = f"TRK-VIEW-{uuid4().hex[:8].upper()}"
|
||||
with self.SessionLocal() as db:
|
||||
row = Request(
|
||||
track_number="TRK-VIEW-OTP",
|
||||
track_number=track_number,
|
||||
client_name="Клиент",
|
||||
client_phone="+79991112233",
|
||||
client_phone=self._unique_phone(),
|
||||
topic_code="consulting",
|
||||
status_code="NEW",
|
||||
description="Проверка просмотра",
|
||||
|
|
@ -148,32 +162,32 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
db.add(row)
|
||||
db.commit()
|
||||
|
||||
no_session = self.client.get("/api/public/requests/TRK-VIEW-OTP")
|
||||
no_session = self.client.get(f"/api/public/requests/{track_number}")
|
||||
self.assertEqual(no_session.status_code, 401)
|
||||
|
||||
with patch("app.api.public.otp._generate_code", return_value="654321"):
|
||||
sent = self.client.post(
|
||||
"/api/public/otp/send",
|
||||
json={"purpose": "VIEW_REQUEST", "track_number": "TRK-VIEW-OTP"},
|
||||
json={"purpose": "VIEW_REQUEST", "track_number": track_number},
|
||||
)
|
||||
self.assertEqual(sent.status_code, 200)
|
||||
self.assertEqual(sent.json()["status"], "sent")
|
||||
|
||||
wrong_code = self.client.post(
|
||||
"/api/public/otp/verify",
|
||||
json={"purpose": "VIEW_REQUEST", "track_number": "TRK-VIEW-OTP", "code": "000000"},
|
||||
json={"purpose": "VIEW_REQUEST", "track_number": track_number, "code": "000000"},
|
||||
)
|
||||
self.assertEqual(wrong_code.status_code, 400)
|
||||
|
||||
verified = self.client.post(
|
||||
"/api/public/otp/verify",
|
||||
json={"purpose": "VIEW_REQUEST", "track_number": "TRK-VIEW-OTP", "code": "654321"},
|
||||
json={"purpose": "VIEW_REQUEST", "track_number": track_number, "code": "654321"},
|
||||
)
|
||||
self.assertEqual(verified.status_code, 200)
|
||||
|
||||
ok = self.client.get("/api/public/requests/TRK-VIEW-OTP")
|
||||
ok = self.client.get(f"/api/public/requests/{track_number}")
|
||||
self.assertEqual(ok.status_code, 200)
|
||||
self.assertEqual(ok.json()["track_number"], "TRK-VIEW-OTP")
|
||||
self.assertEqual(ok.json()["track_number"], track_number)
|
||||
|
||||
denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
|
||||
self.assertEqual(denied_other_track.status_code, 403)
|
||||
|
|
@ -321,7 +335,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
self.assertTrue(created.json()["track_number"].startswith("TRK-"))
|
||||
|
||||
def test_verify_otp_sets_public_cookie_for_configured_ttl(self):
|
||||
phone = "+79990001234"
|
||||
phone = self._unique_phone()
|
||||
with patch("app.api.public.otp._generate_code", return_value="777777"):
|
||||
sent = self.client.post(
|
||||
"/api/public/otp/send",
|
||||
|
|
@ -384,3 +398,62 @@ class PublicRequestCreateTests(unittest.TestCase):
|
|||
payload = decode_jwt(token, settings.PUBLIC_JWT_SECRET)
|
||||
self.assertEqual(payload.get("sub"), phone)
|
||||
self.assertEqual(payload.get("purpose"), "VIEW_REQUEST")
|
||||
|
||||
def test_client_can_create_both_service_request_types_and_audit_is_written(self):
|
||||
phone = "+79997776655"
|
||||
lawyer_id = UUID("11111111-1111-1111-1111-111111111111")
|
||||
with self.SessionLocal() as db:
|
||||
client = Client(full_name="Запросный клиент", phone=phone, responsible="seed")
|
||||
db.add(client)
|
||||
db.flush()
|
||||
req = Request(
|
||||
track_number="TRK-SVC-1",
|
||||
client_id=client.id,
|
||||
client_name=client.full_name,
|
||||
client_phone=client.phone,
|
||||
topic_code="consulting",
|
||||
status_code="IN_PROGRESS",
|
||||
description="Проверка сервисных запросов",
|
||||
extra_fields={},
|
||||
assigned_lawyer_id=str(lawyer_id),
|
||||
)
|
||||
db.add(req)
|
||||
db.commit()
|
||||
|
||||
view_token = create_jwt({"sub": phone, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
|
||||
cookies = {settings.PUBLIC_COOKIE_NAME: view_token}
|
||||
|
||||
curator = self.client.post(
|
||||
"/api/public/requests/TRK-SVC-1/service-requests",
|
||||
cookies=cookies,
|
||||
json={"type": "CURATOR_CONTACT", "body": "Прошу консультацию администратора"},
|
||||
)
|
||||
self.assertEqual(curator.status_code, 201)
|
||||
self.assertEqual(curator.json()["type"], "CURATOR_CONTACT")
|
||||
|
||||
change = self.client.post(
|
||||
"/api/public/requests/TRK-SVC-1/service-requests",
|
||||
cookies=cookies,
|
||||
json={"type": "LAWYER_CHANGE_REQUEST", "body": "Прошу сменить юриста"},
|
||||
)
|
||||
self.assertEqual(change.status_code, 201)
|
||||
self.assertEqual(change.json()["type"], "LAWYER_CHANGE_REQUEST")
|
||||
|
||||
listed = self.client.get("/api/public/requests/TRK-SVC-1/service-requests", cookies=cookies)
|
||||
self.assertEqual(listed.status_code, 200)
|
||||
self.assertEqual(len(listed.json()), 2)
|
||||
|
||||
with self.SessionLocal() as db:
|
||||
rows = db.query(RequestServiceRequest).order_by(RequestServiceRequest.created_at.asc()).all()
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertTrue(rows[0].admin_unread)
|
||||
self.assertTrue(rows[0].lawyer_unread) # curator-contact visible to assigned lawyer
|
||||
self.assertTrue(rows[1].admin_unread)
|
||||
self.assertFalse(rows[1].lawyer_unread) # lawyer-change hidden from assigned lawyer
|
||||
|
||||
audits = (
|
||||
db.query(AuditLog)
|
||||
.filter(AuditLog.entity == "request_service_requests", AuditLog.action == "CREATE_CLIENT_REQUEST")
|
||||
.all()
|
||||
)
|
||||
self.assertEqual(len(audits), 2)
|
||||
|
|
|
|||
115
tests/test_sms_provider_health.py
Normal file
115
tests/test_sms_provider_health.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import os
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||
os.environ.setdefault("S3_BUCKET", "test")
|
||||
|
||||
from app.main import app
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_jwt
|
||||
|
||||
|
||||
class SmsProviderHealthTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.client = TestClient(app)
|
||||
self._settings_backup = {
|
||||
"SMS_PROVIDER": settings.SMS_PROVIDER,
|
||||
"SMSAERO_EMAIL": settings.SMSAERO_EMAIL,
|
||||
"SMSAERO_API_KEY": settings.SMSAERO_API_KEY,
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
for key, value in self._settings_backup.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
@staticmethod
|
||||
def _headers(role: str) -> dict[str, str]:
|
||||
token = create_jwt(
|
||||
{"sub": str(uuid4()), "email": f"{role.lower()}@example.com", "role": role},
|
||||
settings.ADMIN_JWT_SECRET,
|
||||
timedelta(minutes=30),
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def test_sms_provider_health_requires_admin(self):
|
||||
response = self.client.get("/api/admin/system/sms-provider-health", headers=self._headers("LAWYER"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_sms_provider_health_dummy_mode(self):
|
||||
settings.SMS_PROVIDER = "dummy"
|
||||
response = self.client.get("/api/admin/system/sms-provider-health", headers=self._headers("ADMIN"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body.get("provider"), "dummy")
|
||||
self.assertEqual(body.get("status"), "ok")
|
||||
self.assertEqual(body.get("mode"), "mock")
|
||||
self.assertTrue(bool(body.get("can_send")))
|
||||
|
||||
def test_sms_provider_health_smsaero_degraded_when_missing_credentials(self):
|
||||
settings.SMS_PROVIDER = "smsaero"
|
||||
settings.SMSAERO_EMAIL = ""
|
||||
settings.SMSAERO_API_KEY = ""
|
||||
with patch("app.services.sms_service._module_available", return_value=True):
|
||||
response = self.client.get("/api/admin/system/sms-provider-health", headers=self._headers("ADMIN"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body.get("provider"), "smsaero")
|
||||
self.assertEqual(body.get("status"), "degraded")
|
||||
self.assertFalse(bool(body.get("can_send")))
|
||||
checks = body.get("checks") or {}
|
||||
self.assertTrue(bool(checks.get("smsaero_installed")))
|
||||
self.assertFalse(bool(checks.get("email_configured")))
|
||||
self.assertFalse(bool(checks.get("api_key_configured")))
|
||||
|
||||
def test_sms_provider_health_smsaero_ok_when_configured(self):
|
||||
settings.SMS_PROVIDER = "smsaero"
|
||||
settings.SMSAERO_EMAIL = "test@example.com"
|
||||
settings.SMSAERO_API_KEY = "key"
|
||||
with (
|
||||
patch("app.services.sms_service._module_available", return_value=True),
|
||||
patch("app.services.sms_service._get_sms_aero_balance", return_value=(43.51, {"balance": 43.51}, None)),
|
||||
):
|
||||
response = self.client.get("/api/admin/system/sms-provider-health", headers=self._headers("ADMIN"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body.get("provider"), "smsaero")
|
||||
self.assertEqual(body.get("status"), "ok")
|
||||
self.assertTrue(bool(body.get("can_send")))
|
||||
self.assertTrue(bool(body.get("balance_available")))
|
||||
self.assertEqual(float(body.get("balance_amount") or 0), 43.51)
|
||||
|
||||
def test_sms_provider_health_smsaero_degraded_when_balance_unavailable(self):
|
||||
settings.SMS_PROVIDER = "smsaero"
|
||||
settings.SMSAERO_EMAIL = "test@example.com"
|
||||
settings.SMSAERO_API_KEY = "key"
|
||||
with (
|
||||
patch("app.services.sms_service._module_available", return_value=True),
|
||||
patch("app.services.sms_service._get_sms_aero_balance", return_value=(None, None, "network error")),
|
||||
):
|
||||
response = self.client.get("/api/admin/system/sms-provider-health", headers=self._headers("ADMIN"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body.get("provider"), "smsaero")
|
||||
self.assertEqual(body.get("status"), "degraded")
|
||||
self.assertTrue(bool(body.get("can_send")))
|
||||
self.assertFalse(bool(body.get("balance_available")))
|
||||
issues = body.get("issues") or []
|
||||
self.assertTrue(any("network error" in str(item) for item in issues))
|
||||
|
||||
def test_sms_provider_health_unknown_provider(self):
|
||||
settings.SMS_PROVIDER = "unknown-provider"
|
||||
response = self.client.get("/api/admin/system/sms-provider-health", headers=self._headers("ADMIN"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body.get("status"), "error")
|
||||
self.assertFalse(bool(body.get("can_send")))
|
||||
Loading…
Reference in a new issue