Law/tests/test_admin_universal_crud.py
2026-02-23 17:54:19 +03:00

1287 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
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.form_field import FormField
from app.models.message import Message
from app.models.notification import Notification
from app.models.quote import Quote
from app.models.request import Request
from app.models.status import Status
from app.models.status_history import StatusHistory
from app.models.topic_data_template import TopicDataTemplate
from app.models.topic import Topic
from app.models.topic_required_field import TopicRequiredField
from app.models.request_data_requirement import RequestDataRequirement
from app.models.topic_status_transition import TopicStatusTransition
class AdminUniversalCrudTests(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)
Quote.__table__.create(bind=cls.engine)
FormField.__table__.create(bind=cls.engine)
Request.__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)
TopicStatusTransition.__table__.create(bind=cls.engine)
AdminUserTopic.__table__.create(bind=cls.engine)
Notification.__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)
AdminUserTopic.__table__.drop(bind=cls.engine)
RequestDataRequirement.__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)
Request.__table__.drop(bind=cls.engine)
FormField.__table__.drop(bind=cls.engine)
Quote.__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(Status))
db.execute(delete(FormField))
db.execute(delete(Topic))
db.execute(delete(TopicRequiredField))
db.execute(delete(TopicDataTemplate))
db.execute(delete(RequestDataRequirement))
db.execute(delete(TopicStatusTransition))
db.execute(delete(AdminUserTopic))
db.execute(delete(Notification))
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}"}
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_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("quotes", by_table)
self.assertIn("statuses", 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["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 [])))
for table_name, table_meta in by_table.items():
expected_section = "main" if table_name in {"requests", "invoices"} else "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_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_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_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_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_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)
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_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)