mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 18:13:46 +03:00
add deploy
This commit is contained in:
parent
df80b5cb5f
commit
9c0457f07f
37 changed files with 1450 additions and 299 deletions
71
README.md
71
README.md
|
|
@ -1,5 +1,5 @@
|
||||||
# Legal Case Tracker (FastAPI)
|
# Legal Case Tracker (FastAPI)
|
||||||
Backend skeleton: public requests + OTP + public JWT cookie + admin (admin/lawyer) + files (self-hosted S3) + SLA/auto-assign (Celery) + quotes.
|
Backend skeleton: public requests + OTP + public JWT cookie + admin (admin/lawyer) + files (self-hosted S3) + SLA/auto-assign (Celery) + quotes + dedicated chat microservice.
|
||||||
|
|
||||||
## Run (Docker)
|
## Run (Docker)
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -10,6 +10,33 @@ Landing (frontend): http://localhost:8081
|
||||||
Admin UI: http://localhost:8081/admin
|
Admin UI: http://localhost:8081/admin
|
||||||
API (backend): http://localhost:8002
|
API (backend): http://localhost:8002
|
||||||
Swagger: http://localhost:8002/docs
|
Swagger: http://localhost:8002/docs
|
||||||
|
Chat service health (via nginx): http://localhost:8081/chat-health
|
||||||
|
|
||||||
|
## Production (ruakb.ru, 80/443, TLS)
|
||||||
|
Production is configured with a dedicated edge proxy (Caddy) in `docker-compose.prod.yml`.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- DNS `A` record: `ruakb.ru -> 45.150.36.116`
|
||||||
|
- Optional DNS `A` record: `www.ruakb.ru -> 45.150.36.116`
|
||||||
|
- Open server ports: `80/tcp`, `443/tcp`
|
||||||
|
|
||||||
|
Start/update production:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T backend alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use helper script:
|
||||||
|
```bash
|
||||||
|
./scripts/ops/deploy_prod.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
```bash
|
||||||
|
curl -I https://ruakb.ru
|
||||||
|
curl -fsS https://ruakb.ru/health
|
||||||
|
curl -fsS https://ruakb.ru/chat-health
|
||||||
|
```
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -48,3 +75,45 @@ When enabled, real SMS sending is disabled and OTP code is printed to backend lo
|
||||||
|
|
||||||
Admin health-check endpoint (no SMS send):
|
Admin health-check endpoint (no SMS send):
|
||||||
`GET /api/admin/system/sms-provider-health`
|
`GET /api/admin/system/sms-provider-health`
|
||||||
|
|
||||||
|
## Secure Chat (encrypted at rest)
|
||||||
|
Chat logic is isolated in `app/services/chat_secure_service.py`.
|
||||||
|
|
||||||
|
- Message bodies are encrypted before storing in DB (`messages.body`) and transparently decrypted on read.
|
||||||
|
- Encryption key priority:
|
||||||
|
1. `CHAT_ENCRYPTION_SECRET`
|
||||||
|
2. `DATA_ENCRYPTION_SECRET`
|
||||||
|
3. JWT secrets fallback (not recommended for production)
|
||||||
|
|
||||||
|
Recommended production config:
|
||||||
|
```bash
|
||||||
|
CHAT_ENCRYPTION_SECRET=<long-random-secret>
|
||||||
|
DATA_ENCRYPTION_SECRET=<long-random-secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
Chat API runs in a dedicated container (`chat-service`) with separate FastAPI entrypoint:
|
||||||
|
`app/chat_main.py`
|
||||||
|
|
||||||
|
Nginx routes only chat API prefixes to the chat container:
|
||||||
|
- `/api/public/chat/*`
|
||||||
|
- `/api/admin/chat/*`
|
||||||
|
|
||||||
|
## Container health and alerting
|
||||||
|
Docker Compose is configured with:
|
||||||
|
- `restart: unless-stopped` for core services
|
||||||
|
- `healthcheck` for `db`, `redis`, `backend`, `chat-service`, `frontend`
|
||||||
|
- startup ordering via `depends_on: condition: service_healthy`
|
||||||
|
|
||||||
|
Quick checks:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
curl -fsS http://localhost:8081/health
|
||||||
|
curl -fsS http://localhost:8081/chat-health
|
||||||
|
```
|
||||||
|
|
||||||
|
Alert-ready smoke script (for cron/CI):
|
||||||
|
```bash
|
||||||
|
./scripts/ops/check_chat_health.sh
|
||||||
|
```
|
||||||
|
Exit code `0` means healthy, non-zero means alert condition.
|
||||||
|
|
|
||||||
52
alembic/versions/0027_encrypt_chat_messages.py
Normal file
52
alembic/versions/0027_encrypt_chat_messages.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""encrypt historical chat messages at rest
|
||||||
|
|
||||||
|
Revision ID: 0027_encrypt_chat_messages
|
||||||
|
Revises: 0026_srv_req_str_ids
|
||||||
|
Create Date: 2026-02-27 21:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app.services.chat_crypto import decrypt_message_body, encrypt_message_body, is_encrypted_message
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "0027_encrypt_chat_messages"
|
||||||
|
down_revision = "0026_srv_req_str_ids"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(sa.text("SELECT id, body FROM messages WHERE body IS NOT NULL")).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
message_id = row.get("id")
|
||||||
|
body = row.get("body")
|
||||||
|
body_text = str(body or "")
|
||||||
|
if not body_text or is_encrypted_message(body_text):
|
||||||
|
continue
|
||||||
|
encrypted = encrypt_message_body(body_text)
|
||||||
|
bind.execute(
|
||||||
|
sa.text("UPDATE messages SET body = :body WHERE id = :id"),
|
||||||
|
{"body": encrypted, "id": str(message_id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(sa.text("SELECT id, body FROM messages WHERE body IS NOT NULL")).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
message_id = row.get("id")
|
||||||
|
body = row.get("body")
|
||||||
|
body_text = str(body or "")
|
||||||
|
if not body_text or not is_encrypted_message(body_text):
|
||||||
|
continue
|
||||||
|
decrypted = decrypt_message_body(body_text)
|
||||||
|
bind.execute(
|
||||||
|
sa.text("UPDATE messages SET body = :body WHERE id = :id"),
|
||||||
|
{"body": decrypted, "id": str(message_id)},
|
||||||
|
)
|
||||||
|
|
@ -16,7 +16,9 @@ from app.models.request_data_requirement import RequestDataRequirement
|
||||||
from app.models.request_data_template import RequestDataTemplate
|
from app.models.request_data_template import RequestDataTemplate
|
||||||
from app.models.request_data_template_item import RequestDataTemplateItem
|
from app.models.request_data_template_item import RequestDataTemplateItem
|
||||||
from app.models.topic_data_template import TopicDataTemplate
|
from app.models.topic_data_template import TopicDataTemplate
|
||||||
from app.services.chat_service import (
|
from app.services.notifications import EVENT_REQUEST_DATA as NOTIFICATION_EVENT_REQUEST_DATA, notify_request_event, unread_admin_summary
|
||||||
|
from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_client
|
||||||
|
from app.services.chat_secure_service import (
|
||||||
create_admin_or_lawyer_message,
|
create_admin_or_lawyer_message,
|
||||||
get_chat_activity_summary,
|
get_chat_activity_summary,
|
||||||
list_messages_for_request,
|
list_messages_for_request,
|
||||||
|
|
@ -291,6 +293,11 @@ def get_request_live_state(
|
||||||
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
|
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
|
||||||
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
|
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
|
||||||
"typing": typing_rows,
|
"typing": typing_rows,
|
||||||
|
"unread": unread_admin_summary(
|
||||||
|
db,
|
||||||
|
admin_user_id=str(admin.get("sub") or ""),
|
||||||
|
request_id=req.id,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -597,6 +604,7 @@ def upsert_data_request_batch(
|
||||||
req = _request_for_id_or_404(db, request_id)
|
req = _request_for_id_or_404(db, request_id)
|
||||||
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
actor_role = str(admin.get("role") or "").strip().upper()
|
actor_role = str(admin.get("role") or "").strip().upper()
|
||||||
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
|
||||||
body = payload or {}
|
body = payload or {}
|
||||||
raw_items = body.get("items")
|
raw_items = body.get("items")
|
||||||
|
|
@ -639,6 +647,7 @@ def upsert_data_request_batch(
|
||||||
actor_role=role,
|
actor_role=role,
|
||||||
actor_name=actor_name,
|
actor_name=actor_name,
|
||||||
actor_admin_user_id=actor_admin_user_id,
|
actor_admin_user_id=actor_admin_user_id,
|
||||||
|
event_type=NOTIFICATION_EVENT_REQUEST_DATA,
|
||||||
)
|
)
|
||||||
|
|
||||||
message_uuid = existing_message.id
|
message_uuid = existing_message.id
|
||||||
|
|
@ -770,6 +779,18 @@ def upsert_data_request_batch(
|
||||||
for row in existing_message_rows:
|
for row in existing_message_rows:
|
||||||
if row.key not in touched_keys:
|
if row.key not in touched_keys:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
|
mark_unread_for_client(req, EVENT_REQUEST_DATA)
|
||||||
|
req.responsible = responsible
|
||||||
|
db.add(req)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=req,
|
||||||
|
event_type=NOTIFICATION_EVENT_REQUEST_DATA,
|
||||||
|
actor_role=actor_role,
|
||||||
|
actor_admin_user_id=admin.get("sub"),
|
||||||
|
body=f"Обновлен запрос дополнительных данных ({len(normalized_rows)})",
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
fresh_messages = list_messages_for_request(db, req.id)
|
fresh_messages = list_messages_for_request(db, req.id)
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,19 @@ from app.models.table_availability import TableAvailability
|
||||||
from app.schemas.universal import UniversalQuery
|
from app.schemas.universal import UniversalQuery
|
||||||
from app.services.billing_flow import apply_billing_transition_effects
|
from app.services.billing_flow import apply_billing_transition_effects
|
||||||
from app.services.notifications import (
|
from app.services.notifications import (
|
||||||
|
EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT,
|
||||||
EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT,
|
EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT,
|
||||||
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
|
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
|
||||||
|
EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT,
|
||||||
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||||||
mark_admin_notifications_read,
|
mark_admin_notifications_read,
|
||||||
notify_request_event,
|
notify_request_event,
|
||||||
)
|
)
|
||||||
from app.services.request_read_markers import (
|
from app.services.request_read_markers import (
|
||||||
|
EVENT_ASSIGNMENT,
|
||||||
EVENT_ATTACHMENT,
|
EVENT_ATTACHMENT,
|
||||||
EVENT_MESSAGE,
|
EVENT_MESSAGE,
|
||||||
|
EVENT_REASSIGNMENT,
|
||||||
EVENT_STATUS,
|
EVENT_STATUS,
|
||||||
clear_unread_for_lawyer,
|
clear_unread_for_lawyer,
|
||||||
mark_unread_for_client,
|
mark_unread_for_client,
|
||||||
|
|
@ -123,6 +127,26 @@ def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin:
|
||||||
body=f"Файл: {row.file_name}",
|
body=f"Файл: {row.file_name}",
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if table_name == "requests" and isinstance(row, Request):
|
||||||
|
assigned = str(row.assigned_lawyer_id or "").strip()
|
||||||
|
if not assigned:
|
||||||
|
return
|
||||||
|
mark_unread_for_client(row, EVENT_ASSIGNMENT)
|
||||||
|
mark_unread_for_lawyer(row, EVENT_ASSIGNMENT)
|
||||||
|
responsible = _resolve_responsible(admin)
|
||||||
|
row.responsible = responsible
|
||||||
|
db.add(row)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=row,
|
||||||
|
event_type=NOTIFICATION_EVENT_ASSIGNMENT,
|
||||||
|
actor_role=_actor_role(admin),
|
||||||
|
actor_admin_user_id=admin.get("sub"),
|
||||||
|
body=f"Назначен юрист: {assigned}",
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_tables_meta_service(db: Session, admin: dict) -> dict[str, Any]:
|
def list_tables_meta_service(db: Session, admin: dict) -> dict[str, Any]:
|
||||||
|
|
@ -449,6 +473,7 @@ def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db
|
||||||
if "responsible" in _columns_map(model):
|
if "responsible" in _columns_map(model):
|
||||||
clean_payload["responsible"] = responsible
|
clean_payload["responsible"] = responsible
|
||||||
before = _row_to_dict(row)
|
before = _row_to_dict(row)
|
||||||
|
before_assigned_lawyer_id = str(before.get("assigned_lawyer_id") or "").strip() if normalized == "requests" else ""
|
||||||
if normalized == "topic_status_transitions":
|
if normalized == "topic_status_transitions":
|
||||||
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
|
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
|
||||||
next_to = str(clean_payload.get("to_status", before.get("to_status") or "")).strip()
|
next_to = str(clean_payload.get("to_status", before.get("to_status") or "")).strip()
|
||||||
|
|
@ -510,8 +535,35 @@ def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db
|
||||||
),
|
),
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
|
assignment_event_type = None
|
||||||
|
assignment_marker_type = None
|
||||||
|
assignment_event_body = None
|
||||||
|
if normalized == "requests" and not _is_lawyer(admin):
|
||||||
|
after_assigned_candidate = clean_payload.get("assigned_lawyer_id", before_assigned_lawyer_id or None)
|
||||||
|
after_assigned_lawyer_id = str(after_assigned_candidate or "").strip()
|
||||||
|
if after_assigned_lawyer_id and after_assigned_lawyer_id != before_assigned_lawyer_id:
|
||||||
|
if before_assigned_lawyer_id:
|
||||||
|
assignment_event_type = NOTIFICATION_EVENT_REASSIGNMENT
|
||||||
|
assignment_marker_type = EVENT_REASSIGNMENT
|
||||||
|
assignment_event_body = f"Переназначено: {before_assigned_lawyer_id} -> {after_assigned_lawyer_id}"
|
||||||
|
else:
|
||||||
|
assignment_event_type = NOTIFICATION_EVENT_ASSIGNMENT
|
||||||
|
assignment_marker_type = EVENT_ASSIGNMENT
|
||||||
|
assignment_event_body = f"Назначен юрист: {after_assigned_lawyer_id}"
|
||||||
for key, value in clean_payload.items():
|
for key, value in clean_payload.items():
|
||||||
setattr(row, key, value)
|
setattr(row, key, value)
|
||||||
|
if assignment_event_type and assignment_marker_type and isinstance(row, Request):
|
||||||
|
mark_unread_for_client(row, assignment_marker_type)
|
||||||
|
mark_unread_for_lawyer(row, assignment_marker_type)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=row,
|
||||||
|
event_type=assignment_event_type,
|
||||||
|
actor_role=_actor_role(admin),
|
||||||
|
actor_admin_user_id=admin.get("sub"),
|
||||||
|
body=assignment_event_body,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.add(row)
|
db.add(row)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ from app.models.request import Request
|
||||||
from app.models.request_service_request import RequestServiceRequest
|
from app.models.request_service_request import RequestServiceRequest
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
|
from app.services.notifications import (
|
||||||
|
unread_admin_summary,
|
||||||
|
unread_global_summary_for_clients,
|
||||||
|
unread_global_summary_for_lawyers,
|
||||||
|
)
|
||||||
from app.services.sla_metrics import compute_sla_snapshot
|
from app.services.sla_metrics import compute_sla_snapshot
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -99,18 +104,25 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
now_utc = datetime.now(timezone.utc)
|
now_utc = datetime.now(timezone.utc)
|
||||||
month_start, next_month_start = _month_bounds(now_utc)
|
month_start, next_month_start = _month_bounds(now_utc)
|
||||||
|
|
||||||
unread_for_clients = (
|
unread_for_clients_flags = (
|
||||||
db.query(func.count(Request.id))
|
db.query(func.count(Request.id))
|
||||||
.filter(Request.client_has_unread_updates.is_(True))
|
.filter(Request.client_has_unread_updates.is_(True))
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
unread_for_lawyers = (
|
unread_for_lawyers_flags = (
|
||||||
db.query(func.count(Request.id))
|
db.query(func.count(Request.id))
|
||||||
.filter(Request.lawyer_has_unread_updates.is_(True))
|
.filter(Request.lawyer_has_unread_updates.is_(True))
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
unread_for_clients_notifications = unread_global_summary_for_clients(db)
|
||||||
|
unread_for_lawyers_notifications = unread_global_summary_for_lawyers(db)
|
||||||
|
unread_for_clients = max(int(unread_for_clients_flags), int(unread_for_clients_notifications.get("total") or 0))
|
||||||
|
unread_for_lawyers = max(int(unread_for_lawyers_flags), int(unread_for_lawyers_notifications.get("total") or 0))
|
||||||
|
my_unread_notifications = (
|
||||||
|
unread_admin_summary(db, admin_user_id=str(actor_uuid), request_id=None) if actor_uuid is not None else {"total": 0, "by_event": {}}
|
||||||
|
)
|
||||||
if role == "LAWYER" and actor_uuid is not None:
|
if role == "LAWYER" and actor_uuid is not None:
|
||||||
service_request_unread_total = int(
|
service_request_unread_total = int(
|
||||||
db.query(func.count(RequestServiceRequest.id))
|
db.query(func.count(RequestServiceRequest.id))
|
||||||
|
|
@ -267,6 +279,11 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
my_unread_by_event = {str(event_type): int(count) for event_type, count in my_unread_by_event_rows if event_type}
|
my_unread_by_event = {str(event_type): int(count) for event_type, count in my_unread_by_event_rows if event_type}
|
||||||
|
notif_total = int(my_unread_notifications.get("total") or 0)
|
||||||
|
notif_by_event = dict(my_unread_notifications.get("by_event") or {})
|
||||||
|
if notif_total > my_unread_updates:
|
||||||
|
my_unread_updates = notif_total
|
||||||
|
my_unread_by_event = notif_by_event
|
||||||
scoped_lawyer_loads = [row for row in lawyer_loads if str(row["lawyer_id"]) == str(actor_uuid)]
|
scoped_lawyer_loads = [row for row in lawyer_loads if str(row["lawyer_id"]) == str(actor_uuid)]
|
||||||
elif role == "LAWYER":
|
elif role == "LAWYER":
|
||||||
by_status = {}
|
by_status = {}
|
||||||
|
|
@ -293,8 +310,8 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
|
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
|
||||||
my_unread_updates = 0
|
my_unread_updates = int(my_unread_notifications.get("total") or 0)
|
||||||
my_unread_by_event = {}
|
my_unread_by_event = dict(my_unread_notifications.get("by_event") or {})
|
||||||
scoped_lawyer_loads = lawyer_loads
|
scoped_lawyer_loads = lawyer_loads
|
||||||
|
|
||||||
sla_snapshot = compute_sla_snapshot(db)
|
sla_snapshot = compute_sla_snapshot(db)
|
||||||
|
|
@ -319,6 +336,8 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
"unassigned_total": unassigned_total,
|
"unassigned_total": unassigned_total,
|
||||||
"my_unread_updates": my_unread_updates,
|
"my_unread_updates": my_unread_updates,
|
||||||
"my_unread_by_event": my_unread_by_event,
|
"my_unread_by_event": my_unread_by_event,
|
||||||
|
"my_unread_notifications_total": int(my_unread_notifications.get("total") or 0),
|
||||||
|
"my_unread_notifications_by_event": dict(my_unread_notifications.get("by_event") or {}),
|
||||||
"deadline_alert_total": deadline_alert_total,
|
"deadline_alert_total": deadline_alert_total,
|
||||||
"month_revenue": monthly_revenue,
|
"month_revenue": monthly_revenue,
|
||||||
"month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2)
|
"month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2)
|
||||||
|
|
@ -331,6 +350,10 @@ 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", {}),
|
"avg_time_in_status_hours": sla_snapshot.get("avg_time_in_status_hours", {}),
|
||||||
"unread_for_clients": int(unread_for_clients),
|
"unread_for_clients": int(unread_for_clients),
|
||||||
"unread_for_lawyers": int(unread_for_lawyers),
|
"unread_for_lawyers": int(unread_for_lawyers),
|
||||||
|
"unread_for_clients_by_event": dict(unread_for_clients_notifications.get("by_event") or {}),
|
||||||
|
"unread_for_lawyers_by_event": dict(unread_for_lawyers_notifications.get("by_event") or {}),
|
||||||
|
"unread_for_clients_notifications_total": int(unread_for_clients_notifications.get("total") or 0),
|
||||||
|
"unread_for_lawyers_notifications_total": int(unread_for_lawyers_notifications.get("total") or 0),
|
||||||
"service_request_unread_total": int(service_request_unread_total),
|
"service_request_unread_total": int(service_request_unread_total),
|
||||||
"lawyer_loads": scoped_lawyer_loads,
|
"lawyer_loads": scoped_lawyer_loads,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,22 +6,32 @@ from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import case, func, or_, update
|
from sqlalchemy import case, func, or_, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.notification import Notification
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.request_service_request import RequestServiceRequest
|
from app.models.request_service_request import RequestServiceRequest
|
||||||
from app.schemas.admin import RequestAdminCreate, RequestAdminPatch
|
from app.schemas.admin import RequestAdminCreate, RequestAdminPatch
|
||||||
from app.schemas.universal import UniversalQuery
|
from app.schemas.universal import UniversalQuery
|
||||||
from app.services.billing_flow import apply_billing_transition_effects
|
from app.services.billing_flow import apply_billing_transition_effects
|
||||||
from app.services.notifications import (
|
from app.services.notifications import (
|
||||||
|
EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT,
|
||||||
|
EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT,
|
||||||
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||||||
mark_admin_notifications_read,
|
mark_admin_notifications_read,
|
||||||
notify_request_event,
|
notify_request_event,
|
||||||
)
|
)
|
||||||
from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client
|
from app.services.request_read_markers import (
|
||||||
|
EVENT_ASSIGNMENT,
|
||||||
|
EVENT_REASSIGNMENT,
|
||||||
|
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_status import apply_status_change_effects
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
from app.services.status_flow import transition_allowed_for_topic
|
from app.services.status_flow import transition_allowed_for_topic
|
||||||
|
|
@ -68,6 +78,7 @@ def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict
|
||||||
row_ids = [str(row.id) for row in rows if row and row.id]
|
row_ids = [str(row.id) for row in rows if row and row.id]
|
||||||
|
|
||||||
unread_service_requests_by_request: dict[str, int] = {}
|
unread_service_requests_by_request: dict[str, int] = {}
|
||||||
|
viewer_unread_by_request: dict[str, dict[str, Any]] = {}
|
||||||
if row_ids:
|
if row_ids:
|
||||||
unread_query = (
|
unread_query = (
|
||||||
db.query(RequestServiceRequest.request_id, func.count(RequestServiceRequest.id))
|
db.query(RequestServiceRequest.request_id, func.count(RequestServiceRequest.id))
|
||||||
|
|
@ -84,6 +95,37 @@ def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict
|
||||||
unread_rows = unread_query.group_by(RequestServiceRequest.request_id).all()
|
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}
|
unread_service_requests_by_request = {str(request_id): int(count or 0) for request_id, count in unread_rows if request_id}
|
||||||
|
|
||||||
|
if actor:
|
||||||
|
try:
|
||||||
|
actor_uuid = UUID(str(actor))
|
||||||
|
except ValueError:
|
||||||
|
actor_uuid = None
|
||||||
|
if actor_uuid is not None:
|
||||||
|
try:
|
||||||
|
notif_rows = (
|
||||||
|
db.query(Notification.request_id, Notification.event_type, func.count(Notification.id))
|
||||||
|
.filter(
|
||||||
|
Notification.recipient_type == "ADMIN_USER",
|
||||||
|
Notification.recipient_admin_user_id == actor_uuid,
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
Notification.request_id.in_(row_ids),
|
||||||
|
)
|
||||||
|
.group_by(Notification.request_id, Notification.event_type)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
except SQLAlchemyError:
|
||||||
|
notif_rows = []
|
||||||
|
for request_id, event_type, count in notif_rows:
|
||||||
|
request_key = str(request_id or "")
|
||||||
|
if not request_key:
|
||||||
|
continue
|
||||||
|
bucket = viewer_unread_by_request.setdefault(request_key, {"total": 0, "by_event": {}})
|
||||||
|
event_key = str(event_type or "").strip().upper()
|
||||||
|
event_count = int(count or 0)
|
||||||
|
if event_key:
|
||||||
|
bucket["by_event"][event_key] = int(bucket["by_event"].get(event_key, 0)) + event_count
|
||||||
|
bucket["total"] = int(bucket["total"]) + event_count
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"rows": [
|
"rows": [
|
||||||
{
|
{
|
||||||
|
|
@ -106,6 +148,8 @@ def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict
|
||||||
"lawyer_unread_event_type": r.lawyer_unread_event_type,
|
"lawyer_unread_event_type": r.lawyer_unread_event_type,
|
||||||
"service_requests_unread_count": int(unread_service_requests_by_request.get(str(r.id), 0)),
|
"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)),
|
"has_service_requests_unread": bool(unread_service_requests_by_request.get(str(r.id), 0)),
|
||||||
|
"viewer_unread_total": int((viewer_unread_by_request.get(str(r.id)) or {}).get("total", 0)),
|
||||||
|
"viewer_unread_by_event": dict((viewer_unread_by_request.get(str(r.id)) or {}).get("by_event", {})),
|
||||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
@ -193,6 +237,7 @@ def update_request_service(request_id: str, payload: RequestAdminPatch, db: Sess
|
||||||
if row.effective_rate is None and "effective_rate" not in changes:
|
if row.effective_rate is None and "effective_rate" not in changes:
|
||||||
changes["effective_rate"] = assigned_lawyer.default_rate
|
changes["effective_rate"] = assigned_lawyer.default_rate
|
||||||
old_status = str(row.status_code or "")
|
old_status = str(row.status_code or "")
|
||||||
|
old_assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
|
||||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())):
|
if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())):
|
||||||
client = client_for_request_payload_or_400(
|
client = client_for_request_payload_or_400(
|
||||||
|
|
@ -227,6 +272,8 @@ def update_request_service(request_id: str, payload: RequestAdminPatch, db: Sess
|
||||||
)
|
)
|
||||||
for key, value in changes.items():
|
for key, value in changes.items():
|
||||||
setattr(row, key, value)
|
setattr(row, key, value)
|
||||||
|
new_assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
|
||||||
|
assigned_changed = old_assigned_lawyer_id != new_assigned_lawyer_id
|
||||||
if status_changed:
|
if status_changed:
|
||||||
next_status = str(changes.get("status_code") or "")
|
next_status = str(changes.get("status_code") or "")
|
||||||
important_date_at = row.important_date_at
|
important_date_at = row.important_date_at
|
||||||
|
|
@ -260,6 +307,24 @@ def update_request_service(request_id: str, payload: RequestAdminPatch, db: Sess
|
||||||
),
|
),
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
|
if actor_role == "ADMIN" and assigned_changed and new_assigned_lawyer_id:
|
||||||
|
assignment_event_type = NOTIFICATION_EVENT_REASSIGNMENT if old_assigned_lawyer_id else NOTIFICATION_EVENT_ASSIGNMENT
|
||||||
|
marker_event_type = EVENT_REASSIGNMENT if old_assigned_lawyer_id else EVENT_ASSIGNMENT
|
||||||
|
mark_unread_for_client(row, marker_event_type)
|
||||||
|
mark_unread_for_lawyer(row, marker_event_type)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=row,
|
||||||
|
event_type=assignment_event_type,
|
||||||
|
actor_role="ADMIN",
|
||||||
|
actor_admin_user_id=admin.get("sub"),
|
||||||
|
body=(
|
||||||
|
f"Назначен юрист: {new_assigned_lawyer_id}"
|
||||||
|
if not old_assigned_lawyer_id
|
||||||
|
else f"Переназначено: {old_assigned_lawyer_id} -> {new_assigned_lawyer_id}"
|
||||||
|
),
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -388,6 +453,20 @@ def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
|
||||||
|
mark_unread_for_client(row, EVENT_ASSIGNMENT)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=row,
|
||||||
|
event_type=NOTIFICATION_EVENT_ASSIGNMENT,
|
||||||
|
actor_role="LAWYER",
|
||||||
|
actor_admin_user_id=str(lawyer_uuid),
|
||||||
|
body=f"Юрист {str(lawyer.email or lawyer.name or lawyer_uuid)} взял заявку в работу",
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "claimed",
|
"status": "claimed",
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
|
|
@ -462,6 +541,21 @@ def reassign_request_service(request_id: str, lawyer_id: str, db: Session, admin
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
|
||||||
|
mark_unread_for_client(row, EVENT_REASSIGNMENT)
|
||||||
|
mark_unread_for_lawyer(row, EVENT_REASSIGNMENT)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=row,
|
||||||
|
event_type=NOTIFICATION_EVENT_REASSIGNMENT,
|
||||||
|
actor_role="ADMIN",
|
||||||
|
actor_admin_user_id=admin.get("sub"),
|
||||||
|
body=f"Переназначено: {old_assigned} -> {str(lawyer_uuid)}",
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "reassigned",
|
"status": "reassigned",
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.notification import Notification
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
from app.models.status_group import StatusGroup
|
from app.models.status_group import StatusGroup
|
||||||
|
|
@ -76,13 +78,31 @@ def apply_request_special_filters(
|
||||||
raise HTTPException(status_code=400, detail=f'Оператор "{op}" не поддерживается для фильтра "{field}"')
|
raise HTTPException(status_code=400, detail=f'Оператор "{op}" не поддерживается для фильтра "{field}"')
|
||||||
expected = coerce_request_bool_filter_or_400(clause.value)
|
expected = coerce_request_bool_filter_or_400(clause.value)
|
||||||
if field == "has_unread_updates":
|
if field == "has_unread_updates":
|
||||||
|
actor_expr = None
|
||||||
|
try:
|
||||||
|
actor_uuid = UUID(str(actor_id or "").strip())
|
||||||
|
except ValueError:
|
||||||
|
actor_uuid = None
|
||||||
|
if actor_uuid is not None:
|
||||||
|
actor_expr = Request.id.in_(
|
||||||
|
db.query(Notification.request_id).filter(
|
||||||
|
Notification.recipient_type == "ADMIN_USER",
|
||||||
|
Notification.recipient_admin_user_id == actor_uuid,
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
Notification.request_id.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
if role == "LAWYER":
|
if role == "LAWYER":
|
||||||
expr = Request.lawyer_has_unread_updates.is_(True)
|
expr = Request.lawyer_has_unread_updates.is_(True)
|
||||||
|
if actor_expr is not None:
|
||||||
|
expr = or_(expr, actor_expr)
|
||||||
else:
|
else:
|
||||||
expr = or_(
|
expr = or_(
|
||||||
Request.lawyer_has_unread_updates.is_(True),
|
Request.lawyer_has_unread_updates.is_(True),
|
||||||
Request.client_has_unread_updates.is_(True),
|
Request.client_has_unread_updates.is_(True),
|
||||||
)
|
)
|
||||||
|
if actor_expr is not None:
|
||||||
|
expr = or_(expr, actor_expr)
|
||||||
elif field == "deadline_alert":
|
elif field == "deadline_alert":
|
||||||
now_utc = datetime.now(timezone.utc)
|
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)
|
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat, test_utils, system
|
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, test_utils, system
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
|
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
|
||||||
|
|
@ -11,7 +11,6 @@ router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"])
|
||||||
router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"])
|
router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"])
|
||||||
router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"])
|
router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"])
|
||||||
router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"])
|
router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"])
|
||||||
router.include_router(chat.router, prefix="/chat", tags=["AdminChat"])
|
|
||||||
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
|
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
|
||||||
router.include_router(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"])
|
router.include_router(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"])
|
||||||
router.include_router(system.router, prefix="/system", tags=["AdminSystem"])
|
router.include_router(system.router, prefix="/system", tags=["AdminSystem"])
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,15 @@ from app.models.request import Request
|
||||||
from app.models.request_data_requirement import RequestDataRequirement
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
from app.schemas.public import PublicMessageCreate
|
from app.schemas.public import PublicMessageCreate
|
||||||
from app.services.chat_presence import list_typing_presence, set_typing_presence
|
from app.services.chat_presence import list_typing_presence, set_typing_presence
|
||||||
from app.services.chat_service import (
|
from app.services.notifications import EVENT_REQUEST_DATA as NOTIFICATION_EVENT_REQUEST_DATA, notify_request_event, unread_client_summary
|
||||||
|
from app.services.chat_secure_service import (
|
||||||
create_client_message,
|
create_client_message,
|
||||||
get_chat_activity_summary,
|
get_chat_activity_summary,
|
||||||
list_messages_for_request,
|
list_messages_for_request,
|
||||||
serialize_message,
|
serialize_message,
|
||||||
serialize_messages_for_request,
|
serialize_messages_for_request,
|
||||||
)
|
)
|
||||||
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_lawyer
|
from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_lawyer
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -172,6 +173,11 @@ def get_live_chat_state_by_track(
|
||||||
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
|
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
|
||||||
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
|
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
|
||||||
"typing": typing_rows,
|
"typing": typing_rows,
|
||||||
|
"unread": unread_client_summary(
|
||||||
|
db,
|
||||||
|
track_number=req.track_number,
|
||||||
|
request_id=req.id,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -312,8 +318,16 @@ def save_data_request_values(
|
||||||
updated += 1
|
updated += 1
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
mark_unread_for_lawyer(req, EVENT_MESSAGE)
|
mark_unread_for_lawyer(req, EVENT_REQUEST_DATA)
|
||||||
req.responsible = "Клиент"
|
req.responsible = "Клиент"
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=req,
|
||||||
|
event_type=NOTIFICATION_EVENT_REQUEST_DATA,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
body=f"Клиент обновил дополнительные данные ({updated})",
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
db.add(req)
|
db.add(req)
|
||||||
db.commit()
|
db.commit()
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
|
@ -19,18 +20,20 @@ from app.models.client import Client
|
||||||
from app.models.invoice import Invoice
|
from app.models.invoice import Invoice
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.notification import Notification
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.request_service_request import RequestServiceRequest
|
from app.models.request_service_request import RequestServiceRequest
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
from app.models.topic import Topic
|
from app.models.topic import Topic
|
||||||
from app.services.invoice_crypto import decrypt_requisites
|
from app.services.invoice_crypto import decrypt_requisites
|
||||||
from app.services.invoice_pdf import build_invoice_pdf_bytes
|
from app.services.invoice_pdf import build_invoice_pdf_bytes
|
||||||
from app.services.chat_service import create_client_message, list_messages_for_request
|
from app.services.chat_secure_service import create_client_message, list_messages_for_request
|
||||||
from app.services.notifications import (
|
from app.services.notifications import (
|
||||||
get_client_notification,
|
get_client_notification,
|
||||||
list_client_notifications,
|
list_client_notifications,
|
||||||
mark_client_notifications_read,
|
mark_client_notifications_read,
|
||||||
serialize_notification,
|
serialize_notification,
|
||||||
|
unread_client_summary,
|
||||||
)
|
)
|
||||||
from app.services.request_read_markers import clear_unread_for_client
|
from app.services.request_read_markers import clear_unread_for_client
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
|
|
@ -241,6 +244,34 @@ def list_my_requests(
|
||||||
query = query.filter(Request.client_phone == normalized_phone)
|
query = query.filter(Request.client_phone == normalized_phone)
|
||||||
|
|
||||||
rows = query.order_by(Request.updated_at.desc(), Request.created_at.desc(), Request.id.desc()).all()
|
rows = query.order_by(Request.updated_at.desc(), Request.created_at.desc(), Request.id.desc()).all()
|
||||||
|
row_ids = [row.id for row in rows if row and row.id]
|
||||||
|
unread_by_request: dict[str, dict[str, object]] = {}
|
||||||
|
if row_ids:
|
||||||
|
try:
|
||||||
|
notif_rows = (
|
||||||
|
db.query(Notification.request_id, Notification.event_type, func.count(Notification.id))
|
||||||
|
.filter(
|
||||||
|
Notification.recipient_type == "CLIENT",
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
Notification.request_id.in_(row_ids),
|
||||||
|
)
|
||||||
|
.group_by(Notification.request_id, Notification.event_type)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
except SQLAlchemyError:
|
||||||
|
notif_rows = []
|
||||||
|
for request_id, event_type, count in notif_rows:
|
||||||
|
request_key = str(request_id or "")
|
||||||
|
if not request_key:
|
||||||
|
continue
|
||||||
|
bucket = unread_by_request.setdefault(request_key, {"total": 0, "by_event": {}})
|
||||||
|
event_key = str(event_type or "").strip().upper()
|
||||||
|
event_count = int(count or 0)
|
||||||
|
if event_key:
|
||||||
|
by_event = bucket["by_event"] if isinstance(bucket.get("by_event"), dict) else {}
|
||||||
|
by_event[event_key] = int(by_event.get(event_key, 0)) + event_count
|
||||||
|
bucket["by_event"] = by_event
|
||||||
|
bucket["total"] = int(bucket.get("total", 0)) + event_count
|
||||||
return {
|
return {
|
||||||
"rows": [
|
"rows": [
|
||||||
{
|
{
|
||||||
|
|
@ -250,6 +281,8 @@ def list_my_requests(
|
||||||
"status_code": row.status_code,
|
"status_code": row.status_code,
|
||||||
"client_has_unread_updates": bool(row.client_has_unread_updates),
|
"client_has_unread_updates": bool(row.client_has_unread_updates),
|
||||||
"client_unread_event_type": row.client_unread_event_type,
|
"client_unread_event_type": row.client_unread_event_type,
|
||||||
|
"viewer_unread_total": int((unread_by_request.get(str(row.id)) or {}).get("total", 0)),
|
||||||
|
"viewer_unread_by_event": dict((unread_by_request.get(str(row.id)) or {}).get("by_event", {})),
|
||||||
"created_at": _to_iso(row.created_at),
|
"created_at": _to_iso(row.created_at),
|
||||||
"updated_at": _to_iso(row.updated_at),
|
"updated_at": _to_iso(row.updated_at),
|
||||||
}
|
}
|
||||||
|
|
@ -302,6 +335,12 @@ def get_request_by_track(
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(req)
|
db.refresh(req)
|
||||||
|
|
||||||
|
unread_after_open = unread_client_summary(
|
||||||
|
db,
|
||||||
|
track_number=req.track_number,
|
||||||
|
request_id=req.id,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(req.id),
|
"id": str(req.id),
|
||||||
"client_id": str(req.client_id) if req.client_id else None,
|
"client_id": str(req.client_id) if req.client_id else None,
|
||||||
|
|
@ -324,6 +363,8 @@ def get_request_by_track(
|
||||||
"client_unread_event_type": req.client_unread_event_type,
|
"client_unread_event_type": req.client_unread_event_type,
|
||||||
"lawyer_has_unread_updates": req.lawyer_has_unread_updates,
|
"lawyer_has_unread_updates": req.lawyer_has_unread_updates,
|
||||||
"lawyer_unread_event_type": req.lawyer_unread_event_type,
|
"lawyer_unread_event_type": req.lawyer_unread_event_type,
|
||||||
|
"viewer_unread_total": int(unread_after_open.get("total") or 0),
|
||||||
|
"viewer_unread_by_event": dict(unread_after_open.get("by_event") or {}),
|
||||||
"created_at": _to_iso(req.created_at),
|
"created_at": _to_iso(req.created_at),
|
||||||
"updated_at": _to_iso(req.updated_at),
|
"updated_at": _to_iso(req.updated_at),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.public import requests, otp, quotes, uploads, chat, featured_staff
|
from app.api.public import requests, otp, quotes, uploads, featured_staff
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(requests.router, prefix="/requests", tags=["Public"])
|
router.include_router(requests.router, prefix="/requests", tags=["Public"])
|
||||||
|
|
@ -7,4 +7,3 @@ router.include_router(otp.router, prefix="/otp", tags=["Public"])
|
||||||
router.include_router(quotes.router, prefix="/quotes", tags=["Public"])
|
router.include_router(quotes.router, prefix="/quotes", tags=["Public"])
|
||||||
router.include_router(featured_staff.router, prefix="/featured-staff", tags=["Public"])
|
router.include_router(featured_staff.router, prefix="/featured-staff", tags=["Public"])
|
||||||
router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"])
|
router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"])
|
||||||
router.include_router(chat.router, prefix="/chat", tags=["PublicChat"])
|
|
||||||
|
|
|
||||||
31
app/chat_main.py
Normal file
31
app/chat_main.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.api.admin.chat import router as admin_chat_router
|
||||||
|
from app.api.public.chat import router as public_chat_router
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.http_hardening import install_http_hardening
|
||||||
|
|
||||||
|
app = FastAPI(title=f"{settings.APP_NAME}-chat", version="0.1.0")
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
install_http_hardening(app)
|
||||||
|
|
||||||
|
app.include_router(public_chat_router, prefix="/api/public/chat")
|
||||||
|
app.include_router(admin_chat_router, prefix="/api/admin/chat")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
def landing():
|
||||||
|
return JSONResponse({"service": f"{settings.APP_NAME}-chat", "status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
@ -32,6 +32,7 @@ class Settings(BaseSettings):
|
||||||
SMSAERO_API_KEY: str = ""
|
SMSAERO_API_KEY: str = ""
|
||||||
OTP_SMS_TEMPLATE: str = "Your verification code: {code}"
|
OTP_SMS_TEMPLATE: str = "Your verification code: {code}"
|
||||||
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
|
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
|
||||||
|
CHAT_ENCRYPTION_SECRET: str = ""
|
||||||
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
|
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
|
||||||
OTP_SEND_RATE_LIMIT: int = 8
|
OTP_SEND_RATE_LIMIT: int = 8
|
||||||
OTP_VERIFY_RATE_LIMIT: int = 20
|
OTP_VERIFY_RATE_LIMIT: int = 20
|
||||||
|
|
|
||||||
17
app/db/encrypted_types.py
Normal file
17
app/db/encrypted_types.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Text
|
||||||
|
from sqlalchemy.types import TypeDecorator
|
||||||
|
|
||||||
|
from app.services.chat_crypto import decrypt_message_body, encrypt_message_body
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptedChatText(TypeDecorator):
|
||||||
|
impl = Text
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
return encrypt_message_body(value)
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return decrypt_message_body(value)
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import String, Text, Boolean
|
from sqlalchemy import String, Boolean
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
from app.db.encrypted_types import EncryptedChatText
|
||||||
from app.models.common import UUIDMixin, TimestampMixin
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
class Message(Base, UUIDMixin, TimestampMixin):
|
class Message(Base, UUIDMixin, TimestampMixin):
|
||||||
|
|
@ -10,5 +11,5 @@ class Message(Base, UUIDMixin, TimestampMixin):
|
||||||
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||||||
author_type: Mapped[str] = mapped_column(String(20), nullable=False) # CLIENT|LAWYER|SYSTEM
|
author_type: Mapped[str] = mapped_column(String(20), nullable=False) # CLIENT|LAWYER|SYSTEM
|
||||||
author_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
author_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
body: Mapped[str | None] = mapped_column(EncryptedChatText(), nullable=True)
|
||||||
immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
|
||||||
83
app/services/chat_crypto.py
Normal file
83
app/services/chat_crypto.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_VERSION = b"v1"
|
||||||
|
_PREFIX = "chatenc:v1:"
|
||||||
|
|
||||||
|
|
||||||
|
def _encryption_secret() -> str:
|
||||||
|
chat_secret = str(settings.CHAT_ENCRYPTION_SECRET or "").strip()
|
||||||
|
if chat_secret:
|
||||||
|
return chat_secret
|
||||||
|
fallback = str(settings.DATA_ENCRYPTION_SECRET or "").strip()
|
||||||
|
if fallback:
|
||||||
|
return fallback
|
||||||
|
fallback = str(settings.ADMIN_JWT_SECRET or "").strip()
|
||||||
|
if fallback:
|
||||||
|
return fallback
|
||||||
|
fallback = str(settings.PUBLIC_JWT_SECRET or "").strip()
|
||||||
|
if fallback:
|
||||||
|
return fallback
|
||||||
|
raise ValueError("Не задан секрет шифрования чата")
|
||||||
|
|
||||||
|
|
||||||
|
def _key() -> bytes:
|
||||||
|
return hashlib.sha256(_encryption_secret().encode("utf-8")).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
||||||
|
return bytes(x ^ y for x, y in zip(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
def is_encrypted_message(value: str | None) -> bool:
|
||||||
|
token = str(value or "").strip()
|
||||||
|
return token.startswith(_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_message_body(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value)
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
if is_encrypted_message(text):
|
||||||
|
return text
|
||||||
|
raw = text.encode("utf-8")
|
||||||
|
nonce = secrets.token_bytes(16)
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(raw))
|
||||||
|
cipher = _xor_bytes(raw, stream)
|
||||||
|
tag = hmac.new(_key(), _VERSION + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
token = _VERSION + nonce + tag + cipher
|
||||||
|
return _PREFIX + base64.urlsafe_b64encode(token).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_message_body(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value)
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
if not is_encrypted_message(text):
|
||||||
|
return text
|
||||||
|
encoded = text[len(_PREFIX) :]
|
||||||
|
blob = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
||||||
|
if len(blob) < 2 + 16 + 32:
|
||||||
|
raise ValueError("Некорректный зашифрованный формат сообщения")
|
||||||
|
version = blob[:2]
|
||||||
|
nonce = blob[2:18]
|
||||||
|
tag = blob[18:50]
|
||||||
|
cipher = blob[50:]
|
||||||
|
if version != _VERSION:
|
||||||
|
raise ValueError("Неподдерживаемая версия шифрования чата")
|
||||||
|
expected = hmac.new(_key(), version + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
if not hmac.compare_digest(tag, expected):
|
||||||
|
raise ValueError("Поврежденные данные сообщения")
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(cipher))
|
||||||
|
raw = _xor_bytes(cipher, stream)
|
||||||
|
return raw.decode("utf-8")
|
||||||
259
app/services/chat_secure_service.py
Normal file
259
app/services/chat_secure_service.py
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
|
from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event
|
||||||
|
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
|
||||||
|
|
||||||
|
MAX_CHAT_MESSAGE_LEN = 12_000
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_message_body(body: str | None) -> str:
|
||||||
|
message_body = str(body or "").strip()
|
||||||
|
if not message_body:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
|
||||||
|
if len(message_body) > MAX_CHAT_MESSAGE_LEN:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "body" не должно превышать {MAX_CHAT_MESSAGE_LEN} символов')
|
||||||
|
return message_body.replace("\x00", "")
|
||||||
|
|
||||||
|
|
||||||
|
def list_messages_for_request(db: Session, request_id: Any) -> list[Message]:
|
||||||
|
return (
|
||||||
|
db.query(Message)
|
||||||
|
.filter(Message.request_id == request_id)
|
||||||
|
.order_by(Message.created_at.asc(), Message.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_message(row: Message) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"request_id": str(row.request_id),
|
||||||
|
"author_type": row.author_type,
|
||||||
|
"author_name": row.author_name,
|
||||||
|
"body": row.body,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_request_data_label(label: str, limit: int = 18) -> str:
|
||||||
|
text = str(label or "").strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[: max(3, limit - 3)].rstrip() + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Message]) -> list[dict[str, Any]]:
|
||||||
|
message_ids = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
message_ids.append(row.id)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
requirements = (
|
||||||
|
db.query(RequestDataRequirement)
|
||||||
|
.filter(
|
||||||
|
RequestDataRequirement.request_id == request_id,
|
||||||
|
RequestDataRequirement.request_message_id.in_(message_ids) if message_ids else False,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
RequestDataRequirement.request_message_id.asc(),
|
||||||
|
RequestDataRequirement.sort_order.asc(),
|
||||||
|
RequestDataRequirement.created_at.asc(),
|
||||||
|
RequestDataRequirement.id.asc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
if message_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
by_message_id: dict[str, list[RequestDataRequirement]] = {}
|
||||||
|
for item in requirements:
|
||||||
|
mid = str(item.request_message_id or "").strip()
|
||||||
|
if not mid:
|
||||||
|
continue
|
||||||
|
by_message_id.setdefault(mid, []).append(item)
|
||||||
|
file_attachment_ids = []
|
||||||
|
for item in requirements:
|
||||||
|
if str(item.field_type or "").lower() != "file":
|
||||||
|
continue
|
||||||
|
raw = str(item.value_text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
file_attachment_ids.append(raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
attachment_map: dict[str, Attachment] = {}
|
||||||
|
if file_attachment_ids:
|
||||||
|
attachment_rows = db.query(Attachment).filter(Attachment.id.in_(file_attachment_ids)).all()
|
||||||
|
attachment_map = {str(row.id): row for row in attachment_rows}
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
payload = serialize_message(row)
|
||||||
|
linked = by_message_id.get(str(row.id), [])
|
||||||
|
if linked:
|
||||||
|
linked_sorted = sorted(
|
||||||
|
linked,
|
||||||
|
key=lambda req: (
|
||||||
|
1 if str(req.value_text or "").strip() else 0,
|
||||||
|
int(req.sort_order or 0),
|
||||||
|
req.created_at.timestamp() if getattr(req, "created_at", None) else 0,
|
||||||
|
str(req.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
items = []
|
||||||
|
all_filled = True
|
||||||
|
for idx, req in enumerate(linked_sorted, start=1):
|
||||||
|
value_text = str(req.value_text or "").strip()
|
||||||
|
is_filled = bool(value_text)
|
||||||
|
if not is_filled:
|
||||||
|
all_filled = False
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": str(req.id),
|
||||||
|
"index": idx,
|
||||||
|
"key": req.key,
|
||||||
|
"label": req.label,
|
||||||
|
"label_short": _truncate_request_data_label(str(req.label or "")),
|
||||||
|
"field_type": str(req.field_type or "text"),
|
||||||
|
"document_name": req.document_name,
|
||||||
|
"value_text": req.value_text,
|
||||||
|
"value_file": (
|
||||||
|
{
|
||||||
|
"attachment_id": str(attachment_map[value_text].id),
|
||||||
|
"file_name": attachment_map[value_text].file_name,
|
||||||
|
"mime_type": attachment_map[value_text].mime_type,
|
||||||
|
"size_bytes": int(attachment_map[value_text].size_bytes or 0),
|
||||||
|
"download_url": None,
|
||||||
|
}
|
||||||
|
if str(req.field_type or "").lower() == "file" and value_text in attachment_map
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"is_filled": is_filled,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload["message_kind"] = "REQUEST_DATA"
|
||||||
|
payload["request_data_items"] = items
|
||||||
|
payload["request_data_all_filled"] = all_filled and bool(items)
|
||||||
|
payload["body"] = "Запрос"
|
||||||
|
else:
|
||||||
|
payload["message_kind"] = "TEXT"
|
||||||
|
out.append(payload)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def create_client_message(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
body: str,
|
||||||
|
event_type: str = EVENT_MESSAGE,
|
||||||
|
) -> Message:
|
||||||
|
message_body = _normalize_message_body(body)
|
||||||
|
|
||||||
|
row = Message(
|
||||||
|
request_id=request.id,
|
||||||
|
author_type="CLIENT",
|
||||||
|
author_name=request.client_name,
|
||||||
|
body=message_body,
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
normalized_event = str(event_type or EVENT_MESSAGE).strip().upper() or EVENT_MESSAGE
|
||||||
|
mark_unread_for_lawyer(request, normalized_event)
|
||||||
|
request.responsible = "Клиент"
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=request,
|
||||||
|
event_type=normalized_event or NOTIFICATION_EVENT_MESSAGE,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
body=None,
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.add(request)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def create_admin_or_lawyer_message(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
body: str,
|
||||||
|
actor_role: str,
|
||||||
|
actor_name: str,
|
||||||
|
actor_admin_user_id: str | None = None,
|
||||||
|
event_type: str = EVENT_MESSAGE,
|
||||||
|
) -> Message:
|
||||||
|
message_body = _normalize_message_body(body)
|
||||||
|
|
||||||
|
normalized_role = str(actor_role or "").strip().upper()
|
||||||
|
if normalized_role not in {"ADMIN", "LAWYER", "CURATOR"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректная роль автора сообщения")
|
||||||
|
author_type = "LAWYER" if normalized_role in {"LAWYER", "CURATOR"} else "SYSTEM"
|
||||||
|
responsible = str(actor_name or "").strip() or "Администратор системы"
|
||||||
|
|
||||||
|
row = Message(
|
||||||
|
request_id=request.id,
|
||||||
|
author_type=author_type,
|
||||||
|
author_name=str(actor_name or "").strip() or author_type,
|
||||||
|
body=message_body,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
normalized_event = str(event_type or EVENT_MESSAGE).strip().upper() or EVENT_MESSAGE
|
||||||
|
mark_unread_for_client(request, normalized_event)
|
||||||
|
request.responsible = responsible
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=request,
|
||||||
|
event_type=normalized_event or NOTIFICATION_EVENT_MESSAGE,
|
||||||
|
actor_role=normalized_role,
|
||||||
|
actor_admin_user_id=actor_admin_user_id,
|
||||||
|
body=None,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.add(request)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_activity_summary(db: Session, request_id: Any) -> dict[str, Any]:
|
||||||
|
message_count, latest_message_at = (
|
||||||
|
db.query(
|
||||||
|
func.count(Message.id),
|
||||||
|
func.max(func.coalesce(Message.updated_at, Message.created_at)),
|
||||||
|
)
|
||||||
|
.filter(Message.request_id == request_id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
attachment_count, latest_attachment_at = (
|
||||||
|
db.query(
|
||||||
|
func.count(Attachment.id),
|
||||||
|
func.max(func.coalesce(Attachment.updated_at, Attachment.created_at)),
|
||||||
|
)
|
||||||
|
.filter(Attachment.request_id == request_id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
latest_candidates = [value for value in (latest_message_at, latest_attachment_at) if value is not None]
|
||||||
|
latest_activity_at = max(latest_candidates) if latest_candidates else None
|
||||||
|
return {
|
||||||
|
"message_count": int(message_count or 0),
|
||||||
|
"attachment_count": int(attachment_count or 0),
|
||||||
|
"latest_message_at": latest_message_at,
|
||||||
|
"latest_attachment_at": latest_attachment_at,
|
||||||
|
"latest_activity_at": latest_activity_at,
|
||||||
|
}
|
||||||
|
|
@ -1,243 +1,20 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
# Backward-compatible facade: chat domain logic now lives in chat_secure_service.
|
||||||
|
from app.services.chat_secure_service import (
|
||||||
|
create_admin_or_lawyer_message,
|
||||||
|
create_client_message,
|
||||||
|
get_chat_activity_summary,
|
||||||
|
list_messages_for_request,
|
||||||
|
serialize_message,
|
||||||
|
serialize_messages_for_request,
|
||||||
|
)
|
||||||
|
|
||||||
from fastapi import HTTPException
|
__all__ = [
|
||||||
from sqlalchemy import func
|
"create_admin_or_lawyer_message",
|
||||||
from sqlalchemy.orm import Session
|
"create_client_message",
|
||||||
|
"get_chat_activity_summary",
|
||||||
from app.models.message import Message
|
"list_messages_for_request",
|
||||||
from app.models.attachment import Attachment
|
"serialize_message",
|
||||||
from app.models.request import Request
|
"serialize_messages_for_request",
|
||||||
from app.models.request_data_requirement import RequestDataRequirement
|
]
|
||||||
from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event
|
|
||||||
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
|
|
||||||
|
|
||||||
|
|
||||||
def list_messages_for_request(db: Session, request_id: Any) -> list[Message]:
|
|
||||||
return (
|
|
||||||
db.query(Message)
|
|
||||||
.filter(Message.request_id == request_id)
|
|
||||||
.order_by(Message.created_at.asc(), Message.id.asc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_message(row: Message) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": str(row.id),
|
|
||||||
"request_id": str(row.request_id),
|
|
||||||
"author_type": row.author_type,
|
|
||||||
"author_name": row.author_name,
|
|
||||||
"body": row.body,
|
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
||||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _truncate_request_data_label(label: str, limit: int = 18) -> str:
|
|
||||||
text = str(label or "").strip()
|
|
||||||
if len(text) <= limit:
|
|
||||||
return text
|
|
||||||
return text[: max(3, limit - 3)].rstrip() + "..."
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Message]) -> list[dict[str, Any]]:
|
|
||||||
message_ids = []
|
|
||||||
for row in rows:
|
|
||||||
try:
|
|
||||||
message_ids.append(row.id)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
requirements = (
|
|
||||||
db.query(RequestDataRequirement)
|
|
||||||
.filter(
|
|
||||||
RequestDataRequirement.request_id == request_id,
|
|
||||||
RequestDataRequirement.request_message_id.in_(message_ids) if message_ids else False,
|
|
||||||
)
|
|
||||||
.order_by(
|
|
||||||
RequestDataRequirement.request_message_id.asc(),
|
|
||||||
RequestDataRequirement.sort_order.asc(),
|
|
||||||
RequestDataRequirement.created_at.asc(),
|
|
||||||
RequestDataRequirement.id.asc(),
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
if message_ids
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
by_message_id: dict[str, list[RequestDataRequirement]] = {}
|
|
||||||
for item in requirements:
|
|
||||||
mid = str(item.request_message_id or "").strip()
|
|
||||||
if not mid:
|
|
||||||
continue
|
|
||||||
by_message_id.setdefault(mid, []).append(item)
|
|
||||||
file_attachment_ids = []
|
|
||||||
for item in requirements:
|
|
||||||
if str(item.field_type or "").lower() != "file":
|
|
||||||
continue
|
|
||||||
raw = str(item.value_text or "").strip()
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
file_attachment_ids.append(raw)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
attachment_map: dict[str, Attachment] = {}
|
|
||||||
if file_attachment_ids:
|
|
||||||
attachment_rows = db.query(Attachment).filter(Attachment.id.in_(file_attachment_ids)).all()
|
|
||||||
attachment_map = {str(row.id): row for row in attachment_rows}
|
|
||||||
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
for row in rows:
|
|
||||||
payload = serialize_message(row)
|
|
||||||
linked = by_message_id.get(str(row.id), [])
|
|
||||||
if linked:
|
|
||||||
linked_sorted = sorted(
|
|
||||||
linked,
|
|
||||||
key=lambda req: (
|
|
||||||
1 if str(req.value_text or "").strip() else 0,
|
|
||||||
int(req.sort_order or 0),
|
|
||||||
req.created_at.timestamp() if getattr(req, "created_at", None) else 0,
|
|
||||||
str(req.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
items = []
|
|
||||||
all_filled = True
|
|
||||||
for idx, req in enumerate(linked_sorted, start=1):
|
|
||||||
value_text = str(req.value_text or "").strip()
|
|
||||||
is_filled = bool(value_text)
|
|
||||||
if not is_filled:
|
|
||||||
all_filled = False
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"id": str(req.id),
|
|
||||||
"index": idx,
|
|
||||||
"key": req.key,
|
|
||||||
"label": req.label,
|
|
||||||
"label_short": _truncate_request_data_label(str(req.label or "")),
|
|
||||||
"field_type": str(req.field_type or "text"),
|
|
||||||
"document_name": req.document_name,
|
|
||||||
"value_text": req.value_text,
|
|
||||||
"value_file": (
|
|
||||||
{
|
|
||||||
"attachment_id": str(attachment_map[value_text].id),
|
|
||||||
"file_name": attachment_map[value_text].file_name,
|
|
||||||
"mime_type": attachment_map[value_text].mime_type,
|
|
||||||
"size_bytes": int(attachment_map[value_text].size_bytes or 0),
|
|
||||||
"download_url": None,
|
|
||||||
}
|
|
||||||
if str(req.field_type or "").lower() == "file" and value_text in attachment_map
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"is_filled": is_filled,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
payload["message_kind"] = "REQUEST_DATA"
|
|
||||||
payload["request_data_items"] = items
|
|
||||||
payload["request_data_all_filled"] = all_filled and bool(items)
|
|
||||||
payload["body"] = "Запрос"
|
|
||||||
else:
|
|
||||||
payload["message_kind"] = "TEXT"
|
|
||||||
out.append(payload)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def create_client_message(db: Session, *, request: Request, body: str) -> Message:
|
|
||||||
message_body = str(body or "").strip()
|
|
||||||
if not message_body:
|
|
||||||
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
|
|
||||||
|
|
||||||
row = Message(
|
|
||||||
request_id=request.id,
|
|
||||||
author_type="CLIENT",
|
|
||||||
author_name=request.client_name,
|
|
||||||
body=message_body,
|
|
||||||
responsible="Клиент",
|
|
||||||
)
|
|
||||||
mark_unread_for_lawyer(request, EVENT_MESSAGE)
|
|
||||||
request.responsible = "Клиент"
|
|
||||||
notify_request_event(
|
|
||||||
db,
|
|
||||||
request=request,
|
|
||||||
event_type=NOTIFICATION_EVENT_MESSAGE,
|
|
||||||
actor_role="CLIENT",
|
|
||||||
body=message_body,
|
|
||||||
responsible="Клиент",
|
|
||||||
)
|
|
||||||
db.add(row)
|
|
||||||
db.add(request)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(row)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def create_admin_or_lawyer_message(
|
|
||||||
db: Session,
|
|
||||||
*,
|
|
||||||
request: Request,
|
|
||||||
body: str,
|
|
||||||
actor_role: str,
|
|
||||||
actor_name: str,
|
|
||||||
actor_admin_user_id: str | None = None,
|
|
||||||
) -> Message:
|
|
||||||
message_body = str(body or "").strip()
|
|
||||||
if not message_body:
|
|
||||||
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
|
|
||||||
|
|
||||||
normalized_role = str(actor_role or "").strip().upper()
|
|
||||||
if normalized_role not in {"ADMIN", "LAWYER"}:
|
|
||||||
raise HTTPException(status_code=400, detail="Некорректная роль автора сообщения")
|
|
||||||
author_type = "LAWYER" if normalized_role == "LAWYER" else "SYSTEM"
|
|
||||||
responsible = str(actor_name or "").strip() or "Администратор системы"
|
|
||||||
|
|
||||||
row = Message(
|
|
||||||
request_id=request.id,
|
|
||||||
author_type=author_type,
|
|
||||||
author_name=str(actor_name or "").strip() or author_type,
|
|
||||||
body=message_body,
|
|
||||||
responsible=responsible,
|
|
||||||
)
|
|
||||||
mark_unread_for_client(request, EVENT_MESSAGE)
|
|
||||||
request.responsible = responsible
|
|
||||||
notify_request_event(
|
|
||||||
db,
|
|
||||||
request=request,
|
|
||||||
event_type=NOTIFICATION_EVENT_MESSAGE,
|
|
||||||
actor_role=normalized_role,
|
|
||||||
actor_admin_user_id=actor_admin_user_id,
|
|
||||||
body=message_body,
|
|
||||||
responsible=responsible,
|
|
||||||
)
|
|
||||||
db.add(row)
|
|
||||||
db.add(request)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(row)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def get_chat_activity_summary(db: Session, request_id: Any) -> dict[str, Any]:
|
|
||||||
message_count, latest_message_at = (
|
|
||||||
db.query(
|
|
||||||
func.count(Message.id),
|
|
||||||
func.max(func.coalesce(Message.updated_at, Message.created_at)),
|
|
||||||
)
|
|
||||||
.filter(Message.request_id == request_id)
|
|
||||||
.one()
|
|
||||||
)
|
|
||||||
attachment_count, latest_attachment_at = (
|
|
||||||
db.query(
|
|
||||||
func.count(Attachment.id),
|
|
||||||
func.max(func.coalesce(Attachment.updated_at, Attachment.created_at)),
|
|
||||||
)
|
|
||||||
.filter(Attachment.request_id == request_id)
|
|
||||||
.one()
|
|
||||||
)
|
|
||||||
latest_candidates = [value for value in (latest_message_at, latest_attachment_at) if value is not None]
|
|
||||||
latest_activity_at = max(latest_candidates) if latest_candidates else None
|
|
||||||
return {
|
|
||||||
"message_count": int(message_count or 0),
|
|
||||||
"attachment_count": int(attachment_count or 0),
|
|
||||||
"latest_message_at": latest_message_at,
|
|
||||||
"latest_attachment_at": latest_attachment_at,
|
|
||||||
"latest_activity_at": latest_activity_at,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_, func
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -20,12 +20,18 @@ EVENT_MESSAGE = "MESSAGE"
|
||||||
EVENT_ATTACHMENT = "ATTACHMENT"
|
EVENT_ATTACHMENT = "ATTACHMENT"
|
||||||
EVENT_STATUS = "STATUS"
|
EVENT_STATUS = "STATUS"
|
||||||
EVENT_SLA_OVERDUE = "SLA_OVERDUE"
|
EVENT_SLA_OVERDUE = "SLA_OVERDUE"
|
||||||
|
EVENT_REQUEST_DATA = "REQUEST_DATA"
|
||||||
|
EVENT_ASSIGNMENT = "ASSIGNMENT"
|
||||||
|
EVENT_REASSIGNMENT = "REASSIGNMENT"
|
||||||
|
|
||||||
_EVENT_LABELS = {
|
_EVENT_LABELS = {
|
||||||
EVENT_MESSAGE: "Новое сообщение",
|
EVENT_MESSAGE: "Новое сообщение",
|
||||||
EVENT_ATTACHMENT: "Новый файл",
|
EVENT_ATTACHMENT: "Новый файл",
|
||||||
EVENT_STATUS: "Изменен статус",
|
EVENT_STATUS: "Изменен статус",
|
||||||
EVENT_SLA_OVERDUE: "SLA просрочен",
|
EVENT_SLA_OVERDUE: "SLA просрочен",
|
||||||
|
EVENT_REQUEST_DATA: "Запрос/обновление данных",
|
||||||
|
EVENT_ASSIGNMENT: "Заявка назначена",
|
||||||
|
EVENT_REASSIGNMENT: "Заявка переназначена",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -231,7 +237,7 @@ def notify_request_event(
|
||||||
if row is not None:
|
if row is not None:
|
||||||
internal_created += 1
|
internal_created += 1
|
||||||
|
|
||||||
if event in {EVENT_MESSAGE, EVENT_ATTACHMENT}:
|
if event in {EVENT_MESSAGE, EVENT_ATTACHMENT, EVENT_REQUEST_DATA}:
|
||||||
if actor == "CLIENT":
|
if actor == "CLIENT":
|
||||||
_notify_lawyer_if_any()
|
_notify_lawyer_if_any()
|
||||||
_notify_admins()
|
_notify_admins()
|
||||||
|
|
@ -241,6 +247,10 @@ def notify_request_event(
|
||||||
_notify_client()
|
_notify_client()
|
||||||
if actor == "ADMIN":
|
if actor == "ADMIN":
|
||||||
_notify_lawyer_if_any()
|
_notify_lawyer_if_any()
|
||||||
|
elif event in {EVENT_ASSIGNMENT, EVENT_REASSIGNMENT}:
|
||||||
|
_notify_client()
|
||||||
|
_notify_lawyer_if_any()
|
||||||
|
_notify_admins()
|
||||||
elif event == EVENT_SLA_OVERDUE:
|
elif event == EVENT_SLA_OVERDUE:
|
||||||
_notify_lawyer_if_any()
|
_notify_lawyer_if_any()
|
||||||
_notify_admins()
|
_notify_admins()
|
||||||
|
|
@ -434,3 +444,98 @@ def get_client_notification(
|
||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unread_admin_summary(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
admin_user_id: str | uuid.UUID,
|
||||||
|
request_id: uuid.UUID | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
admin_uuid = _as_uuid_or_none(admin_user_id)
|
||||||
|
if admin_uuid is None:
|
||||||
|
return {"total": 0, "by_event": {}}
|
||||||
|
query = db.query(Notification.event_type, func.count(Notification.id)).filter(
|
||||||
|
Notification.recipient_type == RECIPIENT_ADMIN_USER,
|
||||||
|
Notification.recipient_admin_user_id == admin_uuid,
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
)
|
||||||
|
if request_id is not None:
|
||||||
|
query = query.filter(Notification.request_id == request_id)
|
||||||
|
try:
|
||||||
|
rows = query.group_by(Notification.event_type).all()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
return {"total": 0, "by_event": {}}
|
||||||
|
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
|
||||||
|
total = int(sum(by_event.values()))
|
||||||
|
return {"total": total, "by_event": by_event}
|
||||||
|
|
||||||
|
|
||||||
|
def unread_client_summary(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
track_number: str,
|
||||||
|
request_id: uuid.UUID | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
track = _normalize_track(track_number)
|
||||||
|
if not track:
|
||||||
|
return {"total": 0, "by_event": {}}
|
||||||
|
query = db.query(Notification.event_type, func.count(Notification.id)).filter(
|
||||||
|
Notification.recipient_type == RECIPIENT_CLIENT,
|
||||||
|
Notification.recipient_track_number == track,
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
)
|
||||||
|
if request_id is not None:
|
||||||
|
query = query.filter(Notification.request_id == request_id)
|
||||||
|
try:
|
||||||
|
rows = query.group_by(Notification.event_type).all()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
return {"total": 0, "by_event": {}}
|
||||||
|
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
|
||||||
|
total = int(sum(by_event.values()))
|
||||||
|
return {"total": total, "by_event": by_event}
|
||||||
|
|
||||||
|
|
||||||
|
def unread_global_summary_for_clients(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
request_id: uuid.UUID | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
query = db.query(Notification.event_type, func.count(Notification.id)).filter(
|
||||||
|
Notification.recipient_type == RECIPIENT_CLIENT,
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
)
|
||||||
|
if request_id is not None:
|
||||||
|
query = query.filter(Notification.request_id == request_id)
|
||||||
|
try:
|
||||||
|
rows = query.group_by(Notification.event_type).all()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
return {"total": 0, "by_event": {}}
|
||||||
|
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
|
||||||
|
total = int(sum(by_event.values()))
|
||||||
|
return {"total": total, "by_event": by_event}
|
||||||
|
|
||||||
|
|
||||||
|
def unread_global_summary_for_lawyers(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
request_id: uuid.UUID | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
query = (
|
||||||
|
db.query(Notification.event_type, func.count(Notification.id))
|
||||||
|
.join(AdminUser, Notification.recipient_admin_user_id == AdminUser.id)
|
||||||
|
.filter(
|
||||||
|
Notification.recipient_type == RECIPIENT_ADMIN_USER,
|
||||||
|
Notification.is_read.is_(False),
|
||||||
|
AdminUser.role == "LAWYER",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if request_id is not None:
|
||||||
|
query = query.filter(Notification.request_id == request_id)
|
||||||
|
try:
|
||||||
|
rows = query.group_by(Notification.event_type).all()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
return {"total": 0, "by_event": {}}
|
||||||
|
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
|
||||||
|
total = int(sum(by_event.values()))
|
||||||
|
return {"total": total, "by_event": by_event}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ from app.models.request import Request
|
||||||
EVENT_MESSAGE = "MESSAGE"
|
EVENT_MESSAGE = "MESSAGE"
|
||||||
EVENT_ATTACHMENT = "ATTACHMENT"
|
EVENT_ATTACHMENT = "ATTACHMENT"
|
||||||
EVENT_STATUS = "STATUS"
|
EVENT_STATUS = "STATUS"
|
||||||
|
EVENT_REQUEST_DATA = "REQUEST_DATA"
|
||||||
|
EVENT_ASSIGNMENT = "ASSIGNMENT"
|
||||||
|
EVENT_REASSIGNMENT = "REASSIGNMENT"
|
||||||
|
|
||||||
|
|
||||||
def mark_unread_for_client(request: Request, event_type: str) -> None:
|
def mark_unread_for_client(request: Request, event_type: str) -> None:
|
||||||
|
|
|
||||||
|
|
@ -896,6 +896,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
lawyerLoads: [],
|
lawyerLoads: [],
|
||||||
myUnreadByEvent: {},
|
myUnreadByEvent: {},
|
||||||
myUnreadTotal: 0,
|
myUnreadTotal: 0,
|
||||||
|
myUnreadNotificationsTotal: 0,
|
||||||
unreadForClients: 0,
|
unreadForClients: 0,
|
||||||
unreadForLawyers: 0,
|
unreadForLawyers: 0,
|
||||||
serviceRequestUnreadTotal: 0,
|
serviceRequestUnreadTotal: 0,
|
||||||
|
|
@ -1762,7 +1763,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
{ label: "Мои заявки", value: data.assigned_total ?? 0 },
|
{ label: "Мои заявки", value: data.assigned_total ?? 0 },
|
||||||
{ label: "Мои активные", value: data.active_assigned_total ?? 0 },
|
{ label: "Мои активные", value: data.active_assigned_total ?? 0 },
|
||||||
{ label: "Неназначенные", value: data.unassigned_total ?? 0 },
|
{ label: "Неназначенные", value: data.unassigned_total ?? 0 },
|
||||||
{ label: "Мои непрочитанные", value: data.my_unread_updates ?? 0 },
|
{ label: "Мои непрочитанные", value: data.my_unread_notifications_total ?? data.my_unread_updates ?? 0 },
|
||||||
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
|
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
@ -1770,6 +1771,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
{ label: "Назначенные", value: data.assigned_total ?? 0 },
|
{ label: "Назначенные", value: data.assigned_total ?? 0 },
|
||||||
{ label: "Неназначенные", value: data.unassigned_total ?? 0 },
|
{ label: "Неназначенные", value: data.unassigned_total ?? 0 },
|
||||||
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
|
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
|
||||||
|
{ label: "Мои непрочитанные", value: data.my_unread_notifications_total ?? data.my_unread_updates ?? 0 },
|
||||||
{ label: "Выручка (мес.)", value: Number(data.month_revenue ?? 0).toFixed(2) },
|
{ label: "Выручка (мес.)", value: Number(data.month_revenue ?? 0).toFixed(2) },
|
||||||
{ label: "Расходы (мес.)", value: Number(data.month_expenses ?? 0).toFixed(2) },
|
{ label: "Расходы (мес.)", value: Number(data.month_expenses ?? 0).toFixed(2) },
|
||||||
{ label: "Непрочитано юристами", value: data.unread_for_lawyers ?? 0 },
|
{ label: "Непрочитано юристами", value: data.unread_for_lawyers ?? 0 },
|
||||||
|
|
@ -1786,8 +1788,9 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
lawyerLoads: data.lawyer_loads || [],
|
lawyerLoads: data.lawyer_loads || [],
|
||||||
myUnreadByEvent: data.my_unread_by_event || {},
|
myUnreadByEvent: data.my_unread_by_event || {},
|
||||||
myUnreadTotal: Number(data.my_unread_updates || 0),
|
myUnreadTotal: Number(data.my_unread_updates || 0),
|
||||||
unreadForClients: Number(data.unread_for_clients || 0),
|
myUnreadNotificationsTotal: Number(data.my_unread_notifications_total || data.my_unread_updates || 0),
|
||||||
unreadForLawyers: Number(data.unread_for_lawyers || 0),
|
unreadForClients: Number(data.unread_for_clients_notifications_total || data.unread_for_clients || 0),
|
||||||
|
unreadForLawyers: Number(data.unread_for_lawyers_notifications_total || data.unread_for_lawyers || 0),
|
||||||
serviceRequestUnreadTotal: Number(data.service_request_unread_total || 0),
|
serviceRequestUnreadTotal: Number(data.service_request_unread_total || 0),
|
||||||
deadlineAlertTotal: Number(data.deadline_alert_total || 0),
|
deadlineAlertTotal: Number(data.deadline_alert_total || 0),
|
||||||
monthRevenue: Number(data.month_revenue || 0),
|
monthRevenue: Number(data.month_revenue || 0),
|
||||||
|
|
@ -2629,6 +2632,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
lawyerLoads: [],
|
lawyerLoads: [],
|
||||||
myUnreadByEvent: {},
|
myUnreadByEvent: {},
|
||||||
myUnreadTotal: 0,
|
myUnreadTotal: 0,
|
||||||
|
myUnreadNotificationsTotal: 0,
|
||||||
unreadForClients: 0,
|
unreadForClients: 0,
|
||||||
unreadForLawyers: 0,
|
unreadForLawyers: 0,
|
||||||
serviceRequestUnreadTotal: 0,
|
serviceRequestUnreadTotal: 0,
|
||||||
|
|
@ -2789,9 +2793,11 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
|
|
||||||
const topbarUnreadCount = useMemo(() => {
|
const topbarUnreadCount = useMemo(() => {
|
||||||
const roleCode = String(role || "").toUpperCase();
|
const roleCode = String(role || "").toUpperCase();
|
||||||
if (roleCode === "LAWYER") return Number(dashboardData.myUnreadTotal || 0);
|
if (roleCode === "LAWYER" || roleCode === "ADMIN" || roleCode === "CURATOR") {
|
||||||
|
return Number(dashboardData.myUnreadNotificationsTotal || dashboardData.myUnreadTotal || 0);
|
||||||
|
}
|
||||||
return Number(dashboardData.unreadForClients || 0) + Number(dashboardData.unreadForLawyers || 0);
|
return Number(dashboardData.unreadForClients || 0) + Number(dashboardData.unreadForLawyers || 0);
|
||||||
}, [dashboardData.myUnreadTotal, dashboardData.unreadForClients, dashboardData.unreadForLawyers, role]);
|
}, [dashboardData.myUnreadNotificationsTotal, dashboardData.myUnreadTotal, dashboardData.unreadForClients, dashboardData.unreadForLawyers, role]);
|
||||||
|
|
||||||
const topbarDeadlineAlertCount = useMemo(() => Number(dashboardData.deadlineAlertTotal || 0), [dashboardData.deadlineAlertTotal]);
|
const topbarDeadlineAlertCount = useMemo(() => Number(dashboardData.deadlineAlertTotal || 0), [dashboardData.deadlineAlertTotal]);
|
||||||
const topbarServiceRequestUnreadCount = useMemo(
|
const topbarServiceRequestUnreadCount = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -1767,7 +1767,7 @@ export function RequestWorkspace({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{clientDataModal.error ? <div className="status error">{clientDataModal.error}</div> : null}
|
{clientDataModal.error ? <div className="status error">{clientDataModal.error}</div> : null}
|
||||||
<div className={"status" + (clientDataModal.status ? " ok" : "")} id={idMap.dataRequestStatus}>
|
<div className={"request-data-status" + (clientDataModal.status ? " ok" : "")} id={idMap.dataRequestStatus}>
|
||||||
{clientDataModal.status || ""}
|
{clientDataModal.status || ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions modal-actions-right">
|
<div className="modal-actions modal-actions-right">
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,30 @@ import { fmtDate, statusLabel } from "../../shared/utils.js";
|
||||||
function renderRequestUpdatesCell(row, role) {
|
function renderRequestUpdatesCell(row, role) {
|
||||||
const hasServiceRequestUnread = Boolean(row?.has_service_requests_unread);
|
const hasServiceRequestUnread = Boolean(row?.has_service_requests_unread);
|
||||||
const serviceRequestCount = Number(row?.service_requests_unread_count || 0);
|
const serviceRequestCount = Number(row?.service_requests_unread_count || 0);
|
||||||
|
const viewerUnreadTotal = Number(row?.viewer_unread_total || 0);
|
||||||
|
const viewerUnreadByEvent = row?.viewer_unread_by_event && typeof row.viewer_unread_by_event === "object" ? row.viewer_unread_by_event : {};
|
||||||
|
const viewerUnreadLabel =
|
||||||
|
viewerUnreadTotal > 0
|
||||||
|
? Object.entries(viewerUnreadByEvent)
|
||||||
|
.map(([eventType, count]) => {
|
||||||
|
const code = String(eventType || "").toUpperCase();
|
||||||
|
const label = REQUEST_UPDATE_EVENT_LABELS[code] || code.toLowerCase();
|
||||||
|
return label + ": " + String(count || 0);
|
||||||
|
})
|
||||||
|
.join(", ")
|
||||||
|
: "";
|
||||||
if (role === "LAWYER") {
|
if (role === "LAWYER") {
|
||||||
const has = Boolean(row.lawyer_has_unread_updates);
|
const has = Boolean(row.lawyer_has_unread_updates);
|
||||||
const eventType = String(row.lawyer_unread_event_type || "").toUpperCase();
|
const eventType = String(row.lawyer_unread_event_type || "").toUpperCase();
|
||||||
if (!has && !hasServiceRequestUnread) return <span className="request-update-empty">нет</span>;
|
if (!has && !hasServiceRequestUnread && !viewerUnreadTotal) return <span className="request-update-empty">нет</span>;
|
||||||
return (
|
return (
|
||||||
<span className="request-updates-stack">
|
<span className="request-updates-stack">
|
||||||
|
{viewerUnreadTotal > 0 ? (
|
||||||
|
<span className="request-update-chip" title={"Мои непрочитанные: " + (viewerUnreadLabel || String(viewerUnreadTotal))}>
|
||||||
|
<span className="request-update-dot" />
|
||||||
|
{"Мне: " + String(viewerUnreadTotal)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{has ? (
|
{has ? (
|
||||||
<span className="request-update-chip" title={"Есть непрочитанное обновление: " + (REQUEST_UPDATE_EVENT_LABELS[eventType] || eventType.toLowerCase())}>
|
<span className="request-update-chip" title={"Есть непрочитанное обновление: " + (REQUEST_UPDATE_EVENT_LABELS[eventType] || eventType.toLowerCase())}>
|
||||||
<span className="request-update-dot" />
|
<span className="request-update-dot" />
|
||||||
|
|
@ -31,9 +49,15 @@ function renderRequestUpdatesCell(row, role) {
|
||||||
const lawyerHas = Boolean(row.lawyer_has_unread_updates);
|
const lawyerHas = Boolean(row.lawyer_has_unread_updates);
|
||||||
const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase();
|
const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase();
|
||||||
|
|
||||||
if (!clientHas && !lawyerHas && !hasServiceRequestUnread) return <span className="request-update-empty">нет</span>;
|
if (!clientHas && !lawyerHas && !hasServiceRequestUnread && !viewerUnreadTotal) return <span className="request-update-empty">нет</span>;
|
||||||
return (
|
return (
|
||||||
<span className="request-updates-stack">
|
<span className="request-updates-stack">
|
||||||
|
{viewerUnreadTotal > 0 ? (
|
||||||
|
<span className="request-update-chip" title={"Мои непрочитанные: " + (viewerUnreadLabel || String(viewerUnreadTotal))}>
|
||||||
|
<span className="request-update-dot" />
|
||||||
|
{"Мне: " + String(viewerUnreadTotal)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{clientHas ? (
|
{clientHas ? (
|
||||||
<span className="request-update-chip" title={"Клиенту: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || clientType.toLowerCase())}>
|
<span className="request-update-chip" title={"Клиенту: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || clientType.toLowerCase())}>
|
||||||
<span className="request-update-dot" />
|
<span className="request-update-dot" />
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ export const STATUS_KIND_LABELS = {
|
||||||
export const REQUEST_UPDATE_EVENT_LABELS = {
|
export const REQUEST_UPDATE_EVENT_LABELS = {
|
||||||
MESSAGE: "сообщение",
|
MESSAGE: "сообщение",
|
||||||
ATTACHMENT: "файл",
|
ATTACHMENT: "файл",
|
||||||
|
REQUEST_DATA: "данные",
|
||||||
|
ASSIGNMENT: "назначение",
|
||||||
|
REASSIGNMENT: "переназначение",
|
||||||
STATUS: "статус",
|
STATUS: "статус",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -809,7 +809,10 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut
|
||||||
>
|
>
|
||||||
{requestsList.map((row) => (
|
{requestsList.map((row) => (
|
||||||
<option value={String(row.track_number || "")} key={String(row.id || row.track_number || "")}>
|
<option value={String(row.track_number || "")} key={String(row.id || row.track_number || "")}>
|
||||||
{String(row.track_number || "Без номера") + " • " + String(row.status_code || "-")}
|
{String(row.track_number || "Без номера") +
|
||||||
|
" • " +
|
||||||
|
String(row.status_code || "-") +
|
||||||
|
(Number(row?.viewer_unread_total || 0) > 0 ? " • непрочитано: " + String(row.viewer_unread_total) : "")}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -46,6 +46,18 @@ docker compose exec -T backend python -m app.data.cleanup_test_artifacts
|
||||||
docker compose exec -T backend python -m app.data.manual_test_seed
|
docker compose exec -T backend python -m app.data.manual_test_seed
|
||||||
```
|
```
|
||||||
Доступы и список тестовых заявок сохраняются в `/Users/tronosfera/Develop/Law/context/15_manual_test_access.md`.
|
Доступы и список тестовых заявок сохраняются в `/Users/tronosfera/Develop/Law/context/15_manual_test_access.md`.
|
||||||
|
8. Проверка health всех контейнеров после деплоя/рестарта:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
curl -fsS http://localhost:8081/health
|
||||||
|
curl -fsS http://localhost:8081/chat-health
|
||||||
|
```
|
||||||
|
9. Оперативная проверка и alert-код для `backend/chat-service` (под cron/CI):
|
||||||
|
```bash
|
||||||
|
./scripts/ops/check_chat_health.sh
|
||||||
|
echo $? # 0=OK, >0=ALERT
|
||||||
|
```
|
||||||
|
|
||||||
## Матрица проверок по задачам
|
## Матрица проверок по задачам
|
||||||
| ID | Что проверяем | Где тесты | Как запускать |
|
| ID | Что проверяем | Где тесты | Как запускать |
|
||||||
|
|
@ -82,7 +94,7 @@ docker compose exec -T backend python -m app.data.manual_test_seed
|
||||||
| P30 | Отдельная страница работы с заявкой клиента | новые e2e для client workspace route + `tests/test_public_cabinet.py` | добавить e2e route-flow + прогон `test_public_cabinet` |
|
| 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 тесты |
|
| 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 |
|
| P32 | Переключение между заявками клиента | новые e2e multi-request flow + `tests/test_public_cabinet.py` | e2e multi-request + backend regression |
|
||||||
| 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 |
|
| P33 | Чат в отдельном сервисе | `tests/test_public_cabinet.py`, `tests/admin/*` (chat service cases) + UI smoke (`client.js`, `admin.jsx`) + container health | `docker compose run --rm backend python -m unittest tests.test_public_cabinet -v` + `tests/admin/*` (discover) + фронт-сборка admin entrypoint + базовые команды 8-9 |
|
||||||
| P34 | Ненавязчивые цитаты в блоке «Первая консультация» | UI e2e/smoke лендинга | визуальная регрессия лендинга + Playwright public smoke |
|
| 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` |
|
| 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` |
|
| 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` |
|
||||||
|
|
|
||||||
44
context/13_production_deploy_ruakb.md
Normal file
44
context/13_production_deploy_ruakb.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Production deploy (ruakb.ru)
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Развернуть платформу на сервере `45.150.36.116` c HTTPS на `80/443` для домена `ruakb.ru`.
|
||||||
|
|
||||||
|
## Что добавлено
|
||||||
|
- `docker-compose.prod.yml` — production override:
|
||||||
|
- добавлен edge proxy (`caddy`) на `80/443`
|
||||||
|
- отключены внешние порты у внутренних сервисов
|
||||||
|
- `deploy/caddy/Caddyfile` — TLS (Let's Encrypt) + reverse proxy
|
||||||
|
- `scripts/ops/deploy_prod.sh` — запуск стека и миграций
|
||||||
|
|
||||||
|
## Предусловия
|
||||||
|
1. DNS:
|
||||||
|
- `A ruakb.ru -> 45.150.36.116`
|
||||||
|
- `A www.ruakb.ru -> 45.150.36.116` (опционально)
|
||||||
|
2. Открыты порты сервера:
|
||||||
|
- `80/tcp`, `443/tcp`
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
```bash
|
||||||
|
cd /opt/law
|
||||||
|
./scripts/ops/deploy_prod.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
```bash
|
||||||
|
curl -I http://ruakb.ru
|
||||||
|
curl -I https://ruakb.ru
|
||||||
|
curl -fsS https://ruakb.ru/health
|
||||||
|
curl -fsS https://ruakb.ru/chat-health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обновление
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
./scripts/ops/deploy_prod.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Откат
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml down
|
||||||
|
# и вернуть предыдущий git tag/commit
|
||||||
|
```
|
||||||
16
deploy/caddy/Caddyfile
Normal file
16
deploy/caddy/Caddyfile
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
ruakb.ru, www.ruakb.ru {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "DENY"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy frontend:80
|
||||||
|
}
|
||||||
|
|
||||||
|
http://45.150.36.116 {
|
||||||
|
redir https://ruakb.ru{uri} 308
|
||||||
|
}
|
||||||
34
docker-compose.prod.yml
Normal file
34
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
services:
|
||||||
|
edge:
|
||||||
|
image: caddy:2.8.4-alpine
|
||||||
|
container_name: law-edge
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
frontend:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
backend:
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
db:
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
redis:
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
minio:
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
|
@ -4,7 +4,18 @@ services:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
container_name: law-frontend
|
container_name: law-frontend
|
||||||
depends_on: [backend]
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
chat-service:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/health >/dev/null 2>&1 && wget -q -O - http://127.0.0.1/chat-health >/dev/null 2>&1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
ports: ["8081:80", "8080:80"]
|
ports: ["8081:80", "8080:80"]
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
|
|
@ -14,7 +25,9 @@ services:
|
||||||
image: law-e2e-playwright:1.58.2
|
image: law-e2e-playwright:1.58.2
|
||||||
container_name: law-e2e
|
container_name: law-e2e
|
||||||
working_dir: /src/e2e
|
working_dir: /src/e2e
|
||||||
depends_on: [frontend]
|
depends_on:
|
||||||
|
frontend:
|
||||||
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- .:/src
|
- .:/src
|
||||||
- /src/e2e/node_modules
|
- /src/e2e/node_modules
|
||||||
|
|
@ -25,45 +38,102 @@ services:
|
||||||
backend:
|
backend:
|
||||||
build: .
|
build: .
|
||||||
container_name: law-backend
|
container_name: law-backend
|
||||||
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
depends_on: [db, redis, minio]
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_started
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)\""]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 25s
|
||||||
ports: ["8002:8000"]
|
ports: ["8002:8000"]
|
||||||
volumes: [".:/app"]
|
volumes: [".:/app"]
|
||||||
|
|
||||||
|
chat-service:
|
||||||
|
build: .
|
||||||
|
container_name: law-chat-service
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
command: ["uvicorn", "app.chat_main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8001/health', timeout=3)\""]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 25s
|
||||||
|
volumes: [".:/app"]
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
build: .
|
build: .
|
||||||
container_name: law-worker
|
container_name: law-worker
|
||||||
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
depends_on: [db, redis, minio]
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_started
|
||||||
command: ["celery","-A","app.workers.celery_app:celery_app","worker","-Q","notifications,maintenance,uploads","-l","INFO"]
|
command: ["celery","-A","app.workers.celery_app:celery_app","worker","-Q","notifications,maintenance,uploads","-l","INFO"]
|
||||||
volumes: [".:/app"]
|
volumes: [".:/app"]
|
||||||
|
|
||||||
beat:
|
beat:
|
||||||
build: .
|
build: .
|
||||||
container_name: law-beat
|
container_name: law-beat
|
||||||
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
depends_on: [redis]
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
command: ["celery","-A","app.workers.celery_app:celery_app","beat","-l","INFO"]
|
command: ["celery","-A","app.workers.celery_app:celery_app","beat","-l","INFO"]
|
||||||
volumes: [".:/app"]
|
volumes: [".:/app"]
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
container_name: law-db
|
container_name: law-db
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_DB: legal
|
POSTGRES_DB: legal
|
||||||
ports: ["5432:5432"]
|
ports: ["5432:5432"]
|
||||||
volumes: ["pgdata:/var/lib/postgresql/data"]
|
volumes: ["pgdata:/var/lib/postgresql/data"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d legal"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7
|
image: redis:7
|
||||||
container_name: law-redis
|
container_name: law-redis
|
||||||
|
restart: unless-stopped
|
||||||
ports: ["6379:6379"]
|
ports: ["6379:6379"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
container_name: law-minio
|
container_name: law-minio
|
||||||
|
restart: unless-stopped
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: minioadmin
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,24 @@ server {
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /api/public/chat/ {
|
||||||
|
proxy_pass http://chat-service:8001/api/public/chat/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/admin/chat/ {
|
||||||
|
proxy_pass http://chat-service:8001/api/admin/chat/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
@ -83,4 +101,10 @@ server {
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /chat-health {
|
||||||
|
proxy_pass http://chat-service:8001/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
scripts/ops/check_chat_health.sh
Executable file
29
scripts/ops/check_chat_health.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
BASE_URL="${1:-http://localhost:8081}"
|
||||||
|
CHAT_HEALTH_URL="${BASE_URL%/}/chat-health"
|
||||||
|
BACKEND_HEALTH_URL="${BASE_URL%/}/health"
|
||||||
|
|
||||||
|
check_http_200() {
|
||||||
|
url="$1"
|
||||||
|
code="$(curl -sS -o /dev/null -w "%{http_code}" "$url" || true)"
|
||||||
|
[ "$code" = "200" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! check_http_200 "$CHAT_HEALTH_URL"; then
|
||||||
|
echo "[ALERT] chat-service health check failed: $CHAT_HEALTH_URL" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! check_http_200 "$BACKEND_HEALTH_URL"; then
|
||||||
|
echo "[ALERT] backend health check failed: $BACKEND_HEALTH_URL" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker compose ps --format json 2>/dev/null | grep -q '"Health":"unhealthy"'; then
|
||||||
|
echo "[ALERT] at least one container has unhealthy state" >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[OK] chat-service and backend are healthy"
|
||||||
25
scripts/ops/deploy_prod.sh
Executable file
25
scripts/ops/deploy_prod.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
echo "[ERROR] .env not found in $ROOT_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[1/4] Build and start production stack..."
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
|
||||||
|
echo "[2/4] Apply migrations..."
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T backend alembic upgrade head
|
||||||
|
|
||||||
|
echo "[3/4] Service status..."
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
|
echo "[4/4] Smoke checks..."
|
||||||
|
curl -fsS http://localhost/health >/dev/null
|
||||||
|
curl -fsS http://localhost/chat-health >/dev/null
|
||||||
|
|
||||||
|
echo "Done. Open https://ruakb.ru"
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
from tests.admin.base import * # noqa: F401,F403
|
from tests.admin.base import * # noqa: F401,F403
|
||||||
|
from app.chat_main import app as chat_app
|
||||||
|
from app.db.session import get_db
|
||||||
from app.services.chat_presence import clear_presence_for_tests
|
from app.services.chat_presence import clear_presence_for_tests
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -6,8 +8,18 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
clear_presence_for_tests()
|
clear_presence_for_tests()
|
||||||
|
def override_get_db():
|
||||||
|
db = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
chat_app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.chat_client = TestClient(chat_app)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
self.chat_client.close()
|
||||||
|
chat_app.dependency_overrides.clear()
|
||||||
clear_presence_for_tests()
|
clear_presence_for_tests()
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
|
||||||
|
|
@ -396,14 +408,14 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.chat.self@example.com", sub=self_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")
|
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)
|
own_list = self.chat_client.get(f"/api/admin/chat/requests/{own_id}/messages", headers=lawyer_headers)
|
||||||
self.assertEqual(own_list.status_code, 200)
|
self.assertEqual(own_list.status_code, 200)
|
||||||
self.assertEqual(own_list.json()["total"], 1)
|
self.assertEqual(own_list.json()["total"], 1)
|
||||||
|
|
||||||
foreign_list = self.client.get(f"/api/admin/chat/requests/{foreign_id}/messages", headers=lawyer_headers)
|
foreign_list = self.chat_client.get(f"/api/admin/chat/requests/{foreign_id}/messages", headers=lawyer_headers)
|
||||||
self.assertEqual(foreign_list.status_code, 403)
|
self.assertEqual(foreign_list.status_code, 403)
|
||||||
|
|
||||||
own_create = self.client.post(
|
own_create = self.chat_client.post(
|
||||||
f"/api/admin/chat/requests/{own_id}/messages",
|
f"/api/admin/chat/requests/{own_id}/messages",
|
||||||
headers=lawyer_headers,
|
headers=lawyer_headers,
|
||||||
json={"body": "Ответ из chat service"},
|
json={"body": "Ответ из chat service"},
|
||||||
|
|
@ -411,14 +423,14 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
self.assertEqual(own_create.status_code, 201)
|
self.assertEqual(own_create.status_code, 201)
|
||||||
self.assertEqual(own_create.json()["author_type"], "LAWYER")
|
self.assertEqual(own_create.json()["author_type"], "LAWYER")
|
||||||
|
|
||||||
unassigned_create = self.client.post(
|
unassigned_create = self.chat_client.post(
|
||||||
f"/api/admin/chat/requests/{unassigned_id}/messages",
|
f"/api/admin/chat/requests/{unassigned_id}/messages",
|
||||||
headers=lawyer_headers,
|
headers=lawyer_headers,
|
||||||
json={"body": "Нельзя в неназначенную"},
|
json={"body": "Нельзя в неназначенную"},
|
||||||
)
|
)
|
||||||
self.assertEqual(unassigned_create.status_code, 403)
|
self.assertEqual(unassigned_create.status_code, 403)
|
||||||
|
|
||||||
admin_create = self.client.post(
|
admin_create = self.chat_client.post(
|
||||||
f"/api/admin/chat/requests/{foreign_id}/messages",
|
f"/api/admin/chat/requests/{foreign_id}/messages",
|
||||||
headers=admin_headers,
|
headers=admin_headers,
|
||||||
json={"body": "Сообщение администратора"},
|
json={"body": "Сообщение администратора"},
|
||||||
|
|
@ -485,10 +497,10 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.live.self@example.com", sub=self_id)
|
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.live.self@example.com", sub=self_id)
|
||||||
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
|
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||||
|
|
||||||
own_live = self.client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers)
|
own_live = self.chat_client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers)
|
||||||
self.assertEqual(own_live.status_code, 200)
|
self.assertEqual(own_live.status_code, 200)
|
||||||
own_cursor = str(own_live.json().get("cursor") or "")
|
own_cursor = str(own_live.json().get("cursor") or "")
|
||||||
own_live_no_delta = self.client.get(
|
own_live_no_delta = self.chat_client.get(
|
||||||
f"/api/admin/chat/requests/{own_id}/live",
|
f"/api/admin/chat/requests/{own_id}/live",
|
||||||
headers=lawyer_headers,
|
headers=lawyer_headers,
|
||||||
params={"cursor": own_cursor},
|
params={"cursor": own_cursor},
|
||||||
|
|
@ -496,10 +508,10 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
self.assertEqual(own_live_no_delta.status_code, 200)
|
self.assertEqual(own_live_no_delta.status_code, 200)
|
||||||
self.assertFalse(bool(own_live_no_delta.json().get("has_updates")))
|
self.assertFalse(bool(own_live_no_delta.json().get("has_updates")))
|
||||||
|
|
||||||
foreign_live = self.client.get(f"/api/admin/chat/requests/{foreign_id}/live", headers=lawyer_headers)
|
foreign_live = self.chat_client.get(f"/api/admin/chat/requests/{foreign_id}/live", headers=lawyer_headers)
|
||||||
self.assertEqual(foreign_live.status_code, 403)
|
self.assertEqual(foreign_live.status_code, 403)
|
||||||
|
|
||||||
own_typing = self.client.post(
|
own_typing = self.chat_client.post(
|
||||||
f"/api/admin/chat/requests/{own_id}/typing",
|
f"/api/admin/chat/requests/{own_id}/typing",
|
||||||
headers=lawyer_headers,
|
headers=lawyer_headers,
|
||||||
json={"typing": True},
|
json={"typing": True},
|
||||||
|
|
@ -507,14 +519,14 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
self.assertEqual(own_typing.status_code, 200)
|
self.assertEqual(own_typing.status_code, 200)
|
||||||
self.assertTrue(bool(own_typing.json().get("typing")))
|
self.assertTrue(bool(own_typing.json().get("typing")))
|
||||||
|
|
||||||
unassigned_typing = self.client.post(
|
unassigned_typing = self.chat_client.post(
|
||||||
f"/api/admin/chat/requests/{unassigned_id}/typing",
|
f"/api/admin/chat/requests/{unassigned_id}/typing",
|
||||||
headers=lawyer_headers,
|
headers=lawyer_headers,
|
||||||
json={"typing": True},
|
json={"typing": True},
|
||||||
)
|
)
|
||||||
self.assertEqual(unassigned_typing.status_code, 403)
|
self.assertEqual(unassigned_typing.status_code, 403)
|
||||||
|
|
||||||
admin_typing = self.client.post(
|
admin_typing = self.chat_client.post(
|
||||||
f"/api/admin/chat/requests/{own_id}/typing",
|
f"/api/admin/chat/requests/{own_id}/typing",
|
||||||
headers=admin_headers,
|
headers=admin_headers,
|
||||||
json={"typing": True},
|
json={"typing": True},
|
||||||
|
|
@ -522,7 +534,7 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
self.assertEqual(admin_typing.status_code, 200)
|
self.assertEqual(admin_typing.status_code, 200)
|
||||||
self.assertTrue(bool(admin_typing.json().get("typing")))
|
self.assertTrue(bool(admin_typing.json().get("typing")))
|
||||||
|
|
||||||
own_live_with_typing = self.client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers)
|
own_live_with_typing = self.chat_client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers)
|
||||||
self.assertEqual(own_live_with_typing.status_code, 200)
|
self.assertEqual(own_live_with_typing.status_code, 200)
|
||||||
typing_rows = own_live_with_typing.json().get("typing") or []
|
typing_rows = own_live_with_typing.json().get("typing") or []
|
||||||
self.assertTrue(any(str(item.get("actor_role")) == "ADMIN" for item in typing_rows))
|
self.assertTrue(any(str(item.get("actor_role")) == "ADMIN" for item in typing_rows))
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ from app.models.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
from app.models.topic_status_transition import TopicStatusTransition
|
from app.models.topic_status_transition import TopicStatusTransition
|
||||||
|
from app.services.notifications import EVENT_REQUEST_DATA, notify_request_event
|
||||||
from app.workers.tasks import sla as sla_task
|
from app.workers.tasks import sla as sla_task
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -274,6 +275,123 @@ class NotificationFlowTests(unittest.TestCase):
|
||||||
self.assertEqual(len(rows), 1)
|
self.assertEqual(len(rows), 1)
|
||||||
self.assertEqual(str(rows[0].recipient_admin_user_id), lawyer_id)
|
self.assertEqual(str(rows[0].recipient_admin_user_id), lawyer_id)
|
||||||
|
|
||||||
|
def test_admin_reassign_creates_reassignment_notifications_and_unread_markers(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
admin = AdminUser(
|
||||||
|
role="ADMIN",
|
||||||
|
name="Админ",
|
||||||
|
email="root.reassign@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer_old = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Старый",
|
||||||
|
email="lawyer.old@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer_new = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Новый",
|
||||||
|
email="lawyer.new@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([admin, lawyer_old, lawyer_new])
|
||||||
|
db.flush()
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-NOTIF-REASSIGN",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000031",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
description="reassign notification",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(lawyer_old.id),
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
admin_id = str(admin.id)
|
||||||
|
new_lawyer_id = str(lawyer_new.id)
|
||||||
|
|
||||||
|
headers = self._admin_headers(admin_id, "ADMIN", "root.reassign@example.com")
|
||||||
|
resp = self.client.post(
|
||||||
|
f"/api/admin/requests/{request_id}/reassign",
|
||||||
|
headers=headers,
|
||||||
|
json={"lawyer_id": new_lawyer_id},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
rows = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(
|
||||||
|
Notification.request_id == UUID(request_id),
|
||||||
|
Notification.event_type == "REASSIGNMENT",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(rows), 2)
|
||||||
|
self.assertTrue(any(str(row.recipient_track_number or "").upper() == "TRK-NOTIF-REASSIGN" for row in rows))
|
||||||
|
self.assertTrue(any(str(row.recipient_admin_user_id or "") == new_lawyer_id for row in rows))
|
||||||
|
|
||||||
|
req = db.get(Request, UUID(request_id))
|
||||||
|
self.assertIsNotNone(req)
|
||||||
|
self.assertTrue(bool(req.client_has_unread_updates))
|
||||||
|
self.assertEqual(str(req.client_unread_event_type or "").upper(), "REASSIGNMENT")
|
||||||
|
self.assertTrue(bool(req.lawyer_has_unread_updates))
|
||||||
|
self.assertEqual(str(req.lawyer_unread_event_type or "").upper(), "REASSIGNMENT")
|
||||||
|
|
||||||
|
def test_request_data_event_from_client_notifies_lawyer_and_admin(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
admin = AdminUser(
|
||||||
|
role="ADMIN",
|
||||||
|
name="Админ",
|
||||||
|
email="root.data@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист",
|
||||||
|
email="lawyer.data@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([admin, lawyer])
|
||||||
|
db.flush()
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-NOTIF-REQDATA",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000032",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
description="request data notification",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(lawyer.id),
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
result = notify_request_event(
|
||||||
|
db,
|
||||||
|
request=req,
|
||||||
|
event_type=EVENT_REQUEST_DATA,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
body="Клиент обновил доп. данные",
|
||||||
|
responsible="Клиент",
|
||||||
|
send_telegram=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
self.assertEqual(int(result.get("internal_created") or 0), 2)
|
||||||
|
|
||||||
|
rows = db.query(Notification).filter(Notification.event_type == "REQUEST_DATA").all()
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertTrue(any(str(row.recipient_admin_user_id or "") == str(admin.id) for row in rows))
|
||||||
|
self.assertTrue(any(str(row.recipient_admin_user_id or "") == str(lawyer.id) for row in rows))
|
||||||
|
|
||||||
|
|
||||||
class NotificationSlaTests(unittest.TestCase):
|
class NotificationSlaTests(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from unittest.mock import patch
|
||||||
|
|
||||||
from botocore.exceptions import ClientError
|
from botocore.exceptions import ClientError
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, delete
|
from sqlalchemy import create_engine, delete, text
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
|
@ -20,7 +20,8 @@ os.environ.setdefault("S3_BUCKET", "test")
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import create_jwt
|
from app.core.security import create_jwt
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.main import app
|
from app.chat_main import app as chat_app
|
||||||
|
from app.main import app as main_app
|
||||||
from app.models.attachment import Attachment
|
from app.models.attachment import Attachment
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
|
|
@ -103,12 +104,16 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = override_get_db
|
main_app.dependency_overrides[get_db] = override_get_db
|
||||||
self.client = TestClient(app)
|
chat_app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.client = TestClient(main_app)
|
||||||
|
self.chat_client = TestClient(chat_app)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
self.chat_client.close()
|
||||||
self.client.close()
|
self.client.close()
|
||||||
app.dependency_overrides.clear()
|
chat_app.dependency_overrides.clear()
|
||||||
|
main_app.dependency_overrides.clear()
|
||||||
clear_presence_for_tests()
|
clear_presence_for_tests()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -231,7 +236,7 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
cookies = self._public_cookies("TRK-CHAT-001")
|
cookies = self._public_cookies("TRK-CHAT-001")
|
||||||
created = self.client.post(
|
created = self.chat_client.post(
|
||||||
"/api/public/chat/requests/TRK-CHAT-001/messages",
|
"/api/public/chat/requests/TRK-CHAT-001/messages",
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
json={"body": "Сообщение через выделенный сервис"},
|
json={"body": "Сообщение через выделенный сервис"},
|
||||||
|
|
@ -239,14 +244,79 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
self.assertEqual(created.status_code, 201)
|
self.assertEqual(created.status_code, 201)
|
||||||
self.assertEqual(created.json()["author_type"], "CLIENT")
|
self.assertEqual(created.json()["author_type"], "CLIENT")
|
||||||
|
|
||||||
listed = self.client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=cookies)
|
listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=cookies)
|
||||||
self.assertEqual(listed.status_code, 200)
|
self.assertEqual(listed.status_code, 200)
|
||||||
self.assertEqual(len(listed.json()), 1)
|
self.assertEqual(len(listed.json()), 1)
|
||||||
self.assertIn("выделенный сервис", listed.json()[0]["body"])
|
self.assertIn("выделенный сервис", listed.json()[0]["body"])
|
||||||
|
|
||||||
denied = self.client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER"))
|
denied = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER"))
|
||||||
self.assertEqual(denied.status_code, 403)
|
self.assertEqual(denied.status_code, 403)
|
||||||
|
|
||||||
|
def test_chat_message_is_encrypted_at_rest(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-CHAT-ENC",
|
||||||
|
client_name="Клиент Шифрование",
|
||||||
|
client_phone="+79997779999",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="NEW",
|
||||||
|
description="Проверка шифрования чата",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
payload_body = "Секретное сообщение клиента"
|
||||||
|
cookies = self._public_cookies("TRK-CHAT-ENC")
|
||||||
|
created = self.chat_client.post(
|
||||||
|
"/api/public/chat/requests/TRK-CHAT-ENC/messages",
|
||||||
|
cookies=cookies,
|
||||||
|
json={"body": payload_body},
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 201)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
raw_encrypted = db.execute(text("SELECT body FROM messages ORDER BY created_at DESC LIMIT 1")).scalar_one()
|
||||||
|
self.assertTrue(str(raw_encrypted).startswith("chatenc:v1:"))
|
||||||
|
self.assertNotEqual(str(raw_encrypted), payload_body)
|
||||||
|
|
||||||
|
listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-ENC/messages", cookies=cookies)
|
||||||
|
self.assertEqual(listed.status_code, 200)
|
||||||
|
self.assertEqual(listed.json()[0]["body"], payload_body)
|
||||||
|
|
||||||
|
def test_chat_supports_legacy_plaintext_rows(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-CHAT-LEGACY",
|
||||||
|
client_name="Клиент Legacy",
|
||||||
|
client_phone="+79997778888",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="NEW",
|
||||||
|
description="Проверка legacy формата",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
message = Message(
|
||||||
|
request_id=req.id,
|
||||||
|
author_type="LAWYER",
|
||||||
|
author_name="Юрист",
|
||||||
|
body="legacy placeholder",
|
||||||
|
)
|
||||||
|
db.add(message)
|
||||||
|
db.flush()
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE messages SET body = :body WHERE rowid = (SELECT rowid FROM messages ORDER BY created_at DESC LIMIT 1)"),
|
||||||
|
{"body": "LEGACY_PLAINTEXT_MESSAGE"},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
cookies = self._public_cookies("TRK-CHAT-LEGACY")
|
||||||
|
listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-LEGACY/messages", cookies=cookies)
|
||||||
|
self.assertEqual(listed.status_code, 200)
|
||||||
|
self.assertEqual(len(listed.json()), 1)
|
||||||
|
self.assertEqual(listed.json()[0]["body"], "LEGACY_PLAINTEXT_MESSAGE")
|
||||||
|
|
||||||
def test_public_live_endpoint_and_typing_state(self):
|
def test_public_live_endpoint_and_typing_state(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
req = Request(
|
req = Request(
|
||||||
|
|
@ -272,7 +342,7 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
request_id = str(req.id)
|
request_id = str(req.id)
|
||||||
|
|
||||||
cookies = self._public_cookies("TRK-LIVE-001")
|
cookies = self._public_cookies("TRK-LIVE-001")
|
||||||
live_initial = self.client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies)
|
live_initial = self.chat_client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies)
|
||||||
self.assertEqual(live_initial.status_code, 200)
|
self.assertEqual(live_initial.status_code, 200)
|
||||||
live_body = live_initial.json()
|
live_body = live_initial.json()
|
||||||
self.assertTrue(bool(live_body.get("has_updates")))
|
self.assertTrue(bool(live_body.get("has_updates")))
|
||||||
|
|
@ -285,13 +355,13 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
actor_role="LAWYER",
|
actor_role="LAWYER",
|
||||||
typing=True,
|
typing=True,
|
||||||
)
|
)
|
||||||
live_with_typing = self.client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies)
|
live_with_typing = self.chat_client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies)
|
||||||
self.assertEqual(live_with_typing.status_code, 200)
|
self.assertEqual(live_with_typing.status_code, 200)
|
||||||
typing_rows = live_with_typing.json().get("typing") or []
|
typing_rows = live_with_typing.json().get("typing") or []
|
||||||
self.assertTrue(any(str(item.get("actor_label")) == "Юрист Тест" for item in typing_rows))
|
self.assertTrue(any(str(item.get("actor_label")) == "Юрист Тест" for item in typing_rows))
|
||||||
|
|
||||||
current_cursor = str(live_with_typing.json().get("cursor") or "")
|
current_cursor = str(live_with_typing.json().get("cursor") or "")
|
||||||
live_no_delta = self.client.get(
|
live_no_delta = self.chat_client.get(
|
||||||
"/api/public/chat/requests/TRK-LIVE-001/live",
|
"/api/public/chat/requests/TRK-LIVE-001/live",
|
||||||
params={"cursor": current_cursor},
|
params={"cursor": current_cursor},
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
|
|
@ -299,7 +369,7 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
self.assertEqual(live_no_delta.status_code, 200)
|
self.assertEqual(live_no_delta.status_code, 200)
|
||||||
self.assertFalse(bool(live_no_delta.json().get("has_updates")))
|
self.assertFalse(bool(live_no_delta.json().get("has_updates")))
|
||||||
|
|
||||||
typing_on = self.client.post(
|
typing_on = self.chat_client.post(
|
||||||
"/api/public/chat/requests/TRK-LIVE-001/typing",
|
"/api/public/chat/requests/TRK-LIVE-001/typing",
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
json={"typing": True},
|
json={"typing": True},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue