mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 18:13:46 +03:00
fix client chat
This commit is contained in:
parent
69055921cd
commit
7b509f6af3
19 changed files with 3371 additions and 994 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
@ -17,15 +18,53 @@ 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.chat_service import (
|
||||||
create_admin_or_lawyer_message,
|
create_admin_or_lawyer_message,
|
||||||
|
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.chat_presence import list_typing_presence, set_typing_presence
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"}
|
ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cursor(raw: str | None) -> datetime | None:
|
||||||
|
value = str(raw or "").strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = value.replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_or_none(dt: datetime | None) -> str | None:
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
dt = dt.astimezone(timezone.utc)
|
||||||
|
return dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc_datetime(value) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return _parse_cursor(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _request_uuid_or_400(request_id: str) -> UUID:
|
def _request_uuid_or_400(request_id: str) -> UUID:
|
||||||
try:
|
try:
|
||||||
return UUID(str(request_id))
|
return UUID(str(request_id))
|
||||||
|
|
@ -224,6 +263,62 @@ def create_request_message(
|
||||||
return serialize_message(row)
|
return serialize_message(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/{request_id}/live")
|
||||||
|
def get_request_live_state(
|
||||||
|
request_id: str,
|
||||||
|
cursor: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||||
|
):
|
||||||
|
req = _request_for_id_or_404(db, request_id)
|
||||||
|
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||||
|
summary = get_chat_activity_summary(db, req.id)
|
||||||
|
latest_activity_at = _as_utc_datetime(summary.get("latest_activity_at"))
|
||||||
|
latest_activity_iso = _iso_or_none(latest_activity_at)
|
||||||
|
cursor_dt = _parse_cursor(cursor)
|
||||||
|
has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt))
|
||||||
|
|
||||||
|
actor_sub = str(admin.get("sub") or "").strip() or "unknown"
|
||||||
|
actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN"
|
||||||
|
actor_key = f"{actor_role}:{actor_sub}"
|
||||||
|
typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
|
||||||
|
return {
|
||||||
|
"request_id": str(req.id),
|
||||||
|
"cursor": latest_activity_iso,
|
||||||
|
"has_updates": has_updates,
|
||||||
|
"message_count": int(summary.get("message_count") or 0),
|
||||||
|
"attachment_count": int(summary.get("attachment_count") or 0),
|
||||||
|
"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"))),
|
||||||
|
"typing": typing_rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/requests/{request_id}/typing")
|
||||||
|
def set_request_typing_state(
|
||||||
|
request_id: str,
|
||||||
|
payload: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||||
|
):
|
||||||
|
req = _request_for_id_or_404(db, request_id)
|
||||||
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
|
actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN"
|
||||||
|
actor_sub = str(admin.get("sub") or "").strip() or "unknown"
|
||||||
|
actor_email = str(admin.get("email") or "").strip()
|
||||||
|
actor_key = f"{actor_role}:{actor_sub}"
|
||||||
|
actor_label = actor_email or ("Юрист" if actor_role == "LAWYER" else "Администратор")
|
||||||
|
typing = bool((payload or {}).get("typing"))
|
||||||
|
set_typing_presence(
|
||||||
|
request_key=str(req.id),
|
||||||
|
actor_key=actor_key,
|
||||||
|
actor_label=actor_label,
|
||||||
|
actor_role=actor_role,
|
||||||
|
typing=typing,
|
||||||
|
)
|
||||||
|
return {"status": "ok", "typing": typing}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests/{request_id}/data-request-templates")
|
@router.get("/requests/{request_id}/data-request-templates")
|
||||||
def list_data_request_templates(
|
def list_data_request_templates(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
@ -11,12 +12,55 @@ from app.models.message import Message
|
||||||
from app.models.request import Request
|
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_service import create_client_message, list_messages_for_request, serialize_message, serialize_messages_for_request
|
from app.services.chat_presence import list_typing_presence, set_typing_presence
|
||||||
|
from app.services.chat_service import (
|
||||||
|
create_client_message,
|
||||||
|
get_chat_activity_summary,
|
||||||
|
list_messages_for_request,
|
||||||
|
serialize_message,
|
||||||
|
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_MESSAGE, mark_unread_for_lawyer
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cursor(raw: str | None) -> datetime | None:
|
||||||
|
value = str(raw or "").strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = value.replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_or_none(dt: datetime | None) -> str | None:
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
dt = dt.astimezone(timezone.utc)
|
||||||
|
return dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc_datetime(value) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return _parse_cursor(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _attachment_meta_for_public(req: Request, value_text: str | None, db: Session) -> dict | None:
|
def _attachment_meta_for_public(req: Request, value_text: str | None, db: Session) -> dict | None:
|
||||||
raw = str(value_text or "").strip()
|
raw = str(value_text or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
|
|
@ -101,6 +145,58 @@ def create_message_by_track(
|
||||||
return serialize_message(row)
|
return serialize_message(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/{track_number}/live")
|
||||||
|
def get_live_chat_state_by_track(
|
||||||
|
track_number: str,
|
||||||
|
cursor: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, track_number)
|
||||||
|
_ensure_view_access_or_403(session, req)
|
||||||
|
summary = get_chat_activity_summary(db, req.id)
|
||||||
|
latest_activity_at = _as_utc_datetime(summary.get("latest_activity_at"))
|
||||||
|
latest_activity_iso = _iso_or_none(latest_activity_at)
|
||||||
|
cursor_dt = _parse_cursor(cursor)
|
||||||
|
has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt))
|
||||||
|
|
||||||
|
subject = _require_view_session_or_403(session)
|
||||||
|
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}"
|
||||||
|
typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
|
||||||
|
return {
|
||||||
|
"track_number": req.track_number,
|
||||||
|
"cursor": latest_activity_iso,
|
||||||
|
"has_updates": has_updates,
|
||||||
|
"message_count": int(summary.get("message_count") or 0),
|
||||||
|
"attachment_count": int(summary.get("attachment_count") or 0),
|
||||||
|
"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"))),
|
||||||
|
"typing": typing_rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/requests/{track_number}/typing")
|
||||||
|
def set_live_chat_typing_by_track(
|
||||||
|
track_number: str,
|
||||||
|
payload: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, track_number)
|
||||||
|
_ensure_view_access_or_403(session, req)
|
||||||
|
subject = _require_view_session_or_403(session)
|
||||||
|
typing = bool((payload or {}).get("typing"))
|
||||||
|
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}"
|
||||||
|
set_typing_presence(
|
||||||
|
request_key=str(req.id),
|
||||||
|
actor_key=actor_key,
|
||||||
|
actor_label=str(req.client_name or "Клиент"),
|
||||||
|
actor_role="CLIENT",
|
||||||
|
typing=typing,
|
||||||
|
)
|
||||||
|
return {"status": "ok", "typing": typing}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests/{track_number}/data-requests/{message_id}")
|
@router.get("/requests/{track_number}/data-requests/{message_id}")
|
||||||
def get_data_request_by_message(
|
def get_data_request_by_message(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
|
|
||||||
195
app/services/chat_presence.py
Normal file
195
app/services/chat_presence.py
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_DEFAULT_TYPING_TTL_SECONDS = 9
|
||||||
|
|
||||||
|
_redis_client: redis.Redis | None = None
|
||||||
|
_redis_lock = threading.Lock()
|
||||||
|
|
||||||
|
_memory_lock = threading.Lock()
|
||||||
|
_memory_state: dict[str, dict[str, dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_utc(dt: datetime) -> str:
|
||||||
|
return dt.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redis_client() -> redis.Redis | None:
|
||||||
|
global _redis_client
|
||||||
|
if _redis_client is not None:
|
||||||
|
return _redis_client
|
||||||
|
with _redis_lock:
|
||||||
|
if _redis_client is not None:
|
||||||
|
return _redis_client
|
||||||
|
try:
|
||||||
|
client = redis.Redis.from_url(
|
||||||
|
settings.REDIS_URL,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_timeout=0.25,
|
||||||
|
socket_connect_timeout=0.25,
|
||||||
|
)
|
||||||
|
client.ping()
|
||||||
|
_redis_client = client
|
||||||
|
return _redis_client
|
||||||
|
except Exception:
|
||||||
|
_redis_client = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _request_actors_key(request_key: str) -> str:
|
||||||
|
return f"chat:typing:req:{request_key}:actors"
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_payload_key(request_key: str, actor_key: str) -> str:
|
||||||
|
return f"chat:typing:req:{request_key}:actor:{actor_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def set_typing_presence(
|
||||||
|
*,
|
||||||
|
request_key: str,
|
||||||
|
actor_key: str,
|
||||||
|
actor_label: str,
|
||||||
|
actor_role: str,
|
||||||
|
typing: bool,
|
||||||
|
ttl_seconds: int = _DEFAULT_TYPING_TTL_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
normalized_request = str(request_key or "").strip()
|
||||||
|
normalized_actor = str(actor_key or "").strip()
|
||||||
|
if not normalized_request or not normalized_actor:
|
||||||
|
return
|
||||||
|
|
||||||
|
ttl = max(2, int(ttl_seconds or _DEFAULT_TYPING_TTL_SECONDS))
|
||||||
|
if typing:
|
||||||
|
payload = {
|
||||||
|
"actor_key": normalized_actor,
|
||||||
|
"actor_label": str(actor_label or "").strip() or "Собеседник",
|
||||||
|
"actor_role": str(actor_role or "").strip().upper() or "UNKNOWN",
|
||||||
|
"updated_at": _iso_utc(_utc_now()),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
payload = None
|
||||||
|
|
||||||
|
client = _get_redis_client()
|
||||||
|
if client is not None:
|
||||||
|
actors_key = _request_actors_key(normalized_request)
|
||||||
|
actor_payload_key = _actor_payload_key(normalized_request, normalized_actor)
|
||||||
|
try:
|
||||||
|
pipe = client.pipeline()
|
||||||
|
if payload is None:
|
||||||
|
pipe.delete(actor_payload_key)
|
||||||
|
pipe.srem(actors_key, normalized_actor)
|
||||||
|
else:
|
||||||
|
pipe.sadd(actors_key, normalized_actor)
|
||||||
|
pipe.setex(actor_payload_key, ttl, json.dumps(payload, ensure_ascii=False))
|
||||||
|
pipe.expire(actors_key, max(60, ttl * 8))
|
||||||
|
pipe.execute()
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with _memory_lock:
|
||||||
|
actors = _memory_state.setdefault(normalized_request, {})
|
||||||
|
if payload is None:
|
||||||
|
actors.pop(normalized_actor, None)
|
||||||
|
if not actors:
|
||||||
|
_memory_state.pop(normalized_request, None)
|
||||||
|
return
|
||||||
|
expires_at = _utc_now().timestamp() + ttl
|
||||||
|
actors[normalized_actor] = {**payload, "expires_at": expires_at}
|
||||||
|
|
||||||
|
|
||||||
|
def list_typing_presence(
|
||||||
|
*,
|
||||||
|
request_key: str,
|
||||||
|
exclude_actor_key: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
normalized_request = str(request_key or "").strip()
|
||||||
|
if not normalized_request:
|
||||||
|
return []
|
||||||
|
excluded = str(exclude_actor_key or "").strip()
|
||||||
|
now_ts = _utc_now().timestamp()
|
||||||
|
|
||||||
|
client = _get_redis_client()
|
||||||
|
if client is not None:
|
||||||
|
actors_key = _request_actors_key(normalized_request)
|
||||||
|
try:
|
||||||
|
members = list(client.smembers(actors_key) or [])
|
||||||
|
if not members:
|
||||||
|
return []
|
||||||
|
keys = [_actor_payload_key(normalized_request, str(member)) for member in members]
|
||||||
|
rows = client.mget(keys)
|
||||||
|
stale_members: list[str] = []
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for actor, raw in zip(members, rows):
|
||||||
|
actor_str = str(actor)
|
||||||
|
if not raw:
|
||||||
|
stale_members.append(actor_str)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(str(raw))
|
||||||
|
except Exception:
|
||||||
|
stale_members.append(actor_str)
|
||||||
|
continue
|
||||||
|
if excluded and actor_str == excluded:
|
||||||
|
continue
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"actor_key": actor_str,
|
||||||
|
"actor_label": str(payload.get("actor_label") or "Собеседник"),
|
||||||
|
"actor_role": str(payload.get("actor_role") or "UNKNOWN"),
|
||||||
|
"updated_at": str(payload.get("updated_at") or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if stale_members:
|
||||||
|
try:
|
||||||
|
client.srem(actors_key, *stale_members)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with _memory_lock:
|
||||||
|
actors = _memory_state.get(normalized_request) or {}
|
||||||
|
stale: list[str] = []
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for actor_key, payload in actors.items():
|
||||||
|
expires_at = float(payload.get("expires_at") or 0)
|
||||||
|
if expires_at <= now_ts:
|
||||||
|
stale.append(actor_key)
|
||||||
|
continue
|
||||||
|
if excluded and actor_key == excluded:
|
||||||
|
continue
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"actor_key": actor_key,
|
||||||
|
"actor_label": str(payload.get("actor_label") or "Собеседник"),
|
||||||
|
"actor_role": str(payload.get("actor_role") or "UNKNOWN"),
|
||||||
|
"updated_at": str(payload.get("updated_at") or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for actor_key in stale:
|
||||||
|
actors.pop(actor_key, None)
|
||||||
|
if not actors:
|
||||||
|
_memory_state.pop(normalized_request, None)
|
||||||
|
result.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def clear_presence_for_tests() -> None:
|
||||||
|
with _memory_lock:
|
||||||
|
_memory_state.clear()
|
||||||
|
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
|
|
@ -212,3 +213,31 @@ def create_admin_or_lawyer_message(
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return 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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
@ -11,6 +12,9 @@ class SmsDeliveryError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
|
||||||
def _otp_dev_mode_enabled() -> bool:
|
def _otp_dev_mode_enabled() -> bool:
|
||||||
return bool(getattr(settings, "OTP_DEV_MODE", False))
|
return bool(getattr(settings, "OTP_DEV_MODE", False))
|
||||||
|
|
||||||
|
|
@ -39,7 +43,8 @@ def _build_otp_message(*, code: str, purpose: str, track_number: str | None) ->
|
||||||
|
|
||||||
|
|
||||||
def _mock_sms_send(*, phone: str, code: str, purpose: str, track_number: str | None) -> dict[str, Any]:
|
def _mock_sms_send(*, phone: str, code: str, purpose: str, track_number: str | None) -> dict[str, Any]:
|
||||||
print(f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}")
|
line = f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}"
|
||||||
|
logger.warning(line)
|
||||||
return {
|
return {
|
||||||
"provider": "mock_sms",
|
"provider": "mock_sms",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
|
|
@ -203,6 +208,7 @@ def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str |
|
||||||
if _otp_dev_mode_enabled():
|
if _otp_dev_mode_enabled():
|
||||||
payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
|
payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
|
||||||
payload["dev_mode"] = True
|
payload["dev_mode"] = True
|
||||||
|
payload["debug_code"] = str(code)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()
|
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()
|
||||||
|
|
|
||||||
|
|
@ -1911,6 +1911,47 @@
|
||||||
padding-bottom: 0.02rem;
|
padding-bottom: 0.02rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-data-progress-line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: -0.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-progress-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(105, 179, 132, 0.42);
|
||||||
|
background: rgba(57, 138, 88, 0.18);
|
||||||
|
color: #d5f5df;
|
||||||
|
padding: 0.16rem 0.56rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-row-client-value {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
min-height: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-row-client-label {
|
||||||
|
font-size: 0.73rem;
|
||||||
|
color: #94a9c4;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-row-client-text {
|
||||||
|
color: #dbe8f8;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-actions-right {
|
.modal-actions-right {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
@ -2210,6 +2251,35 @@
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-chat-live-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.42rem;
|
||||||
|
margin: -0.1rem 0 0.55rem;
|
||||||
|
min-height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-live-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(68, 208, 138, 0.95);
|
||||||
|
box-shadow: 0 0 0 3px rgba(68, 208, 138, 0.16);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-live-dot.degraded {
|
||||||
|
background: rgba(240, 167, 72, 0.95);
|
||||||
|
box-shadow: 0 0 0 3px rgba(240, 167, 72, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-chat-live-text {
|
||||||
|
font-size: 0.77rem;
|
||||||
|
color: #a8bad0;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
.request-chat-tabs {
|
.request-chat-tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -2393,6 +2463,11 @@
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-data-row-client-value {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-files {
|
.chat-message-files {
|
||||||
|
|
|
||||||
|
|
@ -981,6 +981,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
clearPendingStatusChangePreset,
|
clearPendingStatusChangePreset,
|
||||||
submitRequestStatusChange,
|
submitRequestStatusChange,
|
||||||
submitRequestModalMessage,
|
submitRequestModalMessage,
|
||||||
|
probeRequestLive,
|
||||||
|
setRequestTyping,
|
||||||
loadRequestDataTemplates,
|
loadRequestDataTemplates,
|
||||||
loadRequestDataBatch,
|
loadRequestDataBatch,
|
||||||
loadRequestDataTemplateDetails,
|
loadRequestDataTemplateDetails,
|
||||||
|
|
@ -3129,6 +3131,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
onSaveRequestDataBatch={saveRequestDataBatch}
|
onSaveRequestDataBatch={saveRequestDataBatch}
|
||||||
onChangeStatus={submitRequestStatusChange}
|
onChangeStatus={submitRequestStatusChange}
|
||||||
onConsumePendingStatusChangePreset={clearPendingStatusChangePreset}
|
onConsumePendingStatusChangePreset={clearPendingStatusChangePreset}
|
||||||
|
onLiveProbe={probeRequestLive}
|
||||||
|
onTypingSignal={setRequestTyping}
|
||||||
AttachmentPreviewModalComponent={AttachmentPreviewModal}
|
AttachmentPreviewModalComponent={AttachmentPreviewModal}
|
||||||
StatusLineComponent={StatusLine}
|
StatusLineComponent={StatusLine}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ export function RequestWorkspace({
|
||||||
onUploadRequestAttachment,
|
onUploadRequestAttachment,
|
||||||
onChangeStatus,
|
onChangeStatus,
|
||||||
onConsumePendingStatusChangePreset,
|
onConsumePendingStatusChangePreset,
|
||||||
|
onLiveProbe,
|
||||||
|
onTypingSignal,
|
||||||
domIds,
|
domIds,
|
||||||
AttachmentPreviewModalComponent,
|
AttachmentPreviewModalComponent,
|
||||||
StatusLineComponent,
|
StatusLineComponent,
|
||||||
|
|
@ -97,8 +99,19 @@ export function RequestWorkspace({
|
||||||
status: "",
|
status: "",
|
||||||
error: "",
|
error: "",
|
||||||
});
|
});
|
||||||
|
const [composerFocused, setComposerFocused] = useState(false);
|
||||||
|
const [typingPeers, setTypingPeers] = useState([]);
|
||||||
|
const [liveMode, setLiveMode] = useState("online");
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const statusChangeFileInputRef = useRef(null);
|
const statusChangeFileInputRef = useRef(null);
|
||||||
|
const chatListRef = useRef(null);
|
||||||
|
const liveCursorRef = useRef("");
|
||||||
|
const liveTimerRef = useRef(null);
|
||||||
|
const liveInFlightRef = useRef(false);
|
||||||
|
const liveFailCountRef = useRef(0);
|
||||||
|
const typingHeartbeatRef = useRef(null);
|
||||||
|
const typingActiveRef = useRef(false);
|
||||||
|
const lastAutoScrollCursorRef = useRef("");
|
||||||
const idMap = useMemo(
|
const idMap = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
messagesList: "request-modal-messages",
|
messagesList: "request-modal-messages",
|
||||||
|
|
@ -367,6 +380,40 @@ export function RequestWorkspace({
|
||||||
return map;
|
return map;
|
||||||
}, [safeAttachments]);
|
}, [safeAttachments]);
|
||||||
|
|
||||||
|
const localActivityCursor = useMemo(() => {
|
||||||
|
let latestTs = 0;
|
||||||
|
const pickLatest = (value) => {
|
||||||
|
if (!value) return;
|
||||||
|
const ts = new Date(value).getTime();
|
||||||
|
if (Number.isFinite(ts) && ts > latestTs) latestTs = ts;
|
||||||
|
};
|
||||||
|
safeMessages.forEach((item) => {
|
||||||
|
pickLatest(item?.updated_at);
|
||||||
|
pickLatest(item?.created_at);
|
||||||
|
});
|
||||||
|
safeAttachments.forEach((item) => {
|
||||||
|
pickLatest(item?.updated_at);
|
||||||
|
pickLatest(item?.created_at);
|
||||||
|
});
|
||||||
|
return latestTs > 0 ? new Date(latestTs).toISOString() : "";
|
||||||
|
}, [safeAttachments, safeMessages]);
|
||||||
|
|
||||||
|
const typingHintText = useMemo(() => {
|
||||||
|
const rows = Array.isArray(typingPeers) ? typingPeers : [];
|
||||||
|
if (!rows.length) return "";
|
||||||
|
const labels = rows
|
||||||
|
.map((item) => String(item?.actor_label || item?.label || "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!labels.length) return "Собеседник печатает...";
|
||||||
|
const unique = [];
|
||||||
|
labels.forEach((label) => {
|
||||||
|
if (!unique.includes(label)) unique.push(label);
|
||||||
|
});
|
||||||
|
if (unique.length === 1) return unique[0] + " печатает...";
|
||||||
|
if (unique.length === 2) return unique[0] + " и " + unique[1] + " печатают...";
|
||||||
|
return unique[0] + ", " + unique[1] + " и еще " + String(unique.length - 2) + " печатают...";
|
||||||
|
}, [typingPeers]);
|
||||||
|
|
||||||
const openAttachmentFromMessage = (item) => {
|
const openAttachmentFromMessage = (item) => {
|
||||||
if (!item?.download_url) return;
|
if (!item?.download_url) return;
|
||||||
const kind = detectAttachmentPreviewKind(item.file_name, item.mime_type);
|
const kind = detectAttachmentPreviewKind(item.file_name, item.mime_type);
|
||||||
|
|
@ -377,6 +424,121 @@ export function RequestWorkspace({
|
||||||
openPreview(item);
|
openPreview(item);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
liveCursorRef.current = localActivityCursor || "";
|
||||||
|
}, [localActivityCursor, row?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!row || typeof onLiveProbe !== "function") {
|
||||||
|
setTypingPeers([]);
|
||||||
|
setLiveMode("online");
|
||||||
|
if (liveTimerRef.current) {
|
||||||
|
clearTimeout(liveTimerRef.current);
|
||||||
|
liveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
liveInFlightRef.current = false;
|
||||||
|
liveFailCountRef.current = 0;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const scheduleNext = (ms) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (liveTimerRef.current) clearTimeout(liveTimerRef.current);
|
||||||
|
liveTimerRef.current = setTimeout(runProbe, ms);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runProbe = async () => {
|
||||||
|
if (cancelled || liveInFlightRef.current) return;
|
||||||
|
liveInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
const payload = await onLiveProbe({ cursor: liveCursorRef.current });
|
||||||
|
const cursor = String(payload?.cursor || "").trim();
|
||||||
|
if (cursor) liveCursorRef.current = cursor;
|
||||||
|
setTypingPeers(Array.isArray(payload?.typing) ? payload.typing : []);
|
||||||
|
liveFailCountRef.current = 0;
|
||||||
|
setLiveMode("online");
|
||||||
|
} catch (_) {
|
||||||
|
liveFailCountRef.current += 1;
|
||||||
|
setLiveMode(liveFailCountRef.current >= 3 ? "degraded" : "online");
|
||||||
|
} finally {
|
||||||
|
liveInFlightRef.current = false;
|
||||||
|
const hidden = typeof document !== "undefined" && document.visibilityState === "hidden";
|
||||||
|
const baseInterval = hidden ? 8000 : 2500;
|
||||||
|
const failStep = Math.min(5, Math.max(0, liveFailCountRef.current));
|
||||||
|
const backoffInterval = failStep > 0 ? Math.min(30000, baseInterval * Math.pow(2, failStep - 1)) : baseInterval;
|
||||||
|
scheduleNext(backoffInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
runProbe();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (liveTimerRef.current) {
|
||||||
|
clearTimeout(liveTimerRef.current);
|
||||||
|
liveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
liveInFlightRef.current = false;
|
||||||
|
liveFailCountRef.current = 0;
|
||||||
|
setTypingPeers([]);
|
||||||
|
setLiveMode("online");
|
||||||
|
};
|
||||||
|
}, [onLiveProbe, row, trackNumber]);
|
||||||
|
|
||||||
|
const typingEnabled = Boolean(
|
||||||
|
row &&
|
||||||
|
typeof onTypingSignal === "function" &&
|
||||||
|
!loading &&
|
||||||
|
!fileUploading &&
|
||||||
|
composerFocused &&
|
||||||
|
String(messageDraft || "").trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof onTypingSignal !== "function" || !row) {
|
||||||
|
if (typingHeartbeatRef.current) {
|
||||||
|
clearInterval(typingHeartbeatRef.current);
|
||||||
|
typingHeartbeatRef.current = null;
|
||||||
|
}
|
||||||
|
typingActiveRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typingEnabled) {
|
||||||
|
if (!typingActiveRef.current) {
|
||||||
|
typingActiveRef.current = true;
|
||||||
|
void onTypingSignal({ typing: true }).catch(() => null);
|
||||||
|
}
|
||||||
|
if (!typingHeartbeatRef.current) {
|
||||||
|
typingHeartbeatRef.current = setInterval(() => {
|
||||||
|
void onTypingSignal({ typing: true }).catch(() => null);
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typingHeartbeatRef.current) {
|
||||||
|
clearInterval(typingHeartbeatRef.current);
|
||||||
|
typingHeartbeatRef.current = null;
|
||||||
|
}
|
||||||
|
if (typingActiveRef.current) {
|
||||||
|
typingActiveRef.current = false;
|
||||||
|
void onTypingSignal({ typing: false }).catch(() => null);
|
||||||
|
}
|
||||||
|
}, [onTypingSignal, row, typingEnabled]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (typingHeartbeatRef.current) {
|
||||||
|
clearInterval(typingHeartbeatRef.current);
|
||||||
|
typingHeartbeatRef.current = null;
|
||||||
|
}
|
||||||
|
if (typingActiveRef.current && typeof onTypingSignal === "function") {
|
||||||
|
typingActiveRef.current = false;
|
||||||
|
void onTypingSignal({ typing: false }).catch(() => null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onTypingSignal]
|
||||||
|
);
|
||||||
|
|
||||||
const newDataRequestRow = (source) => {
|
const newDataRequestRow = (source) => {
|
||||||
const item = source || {};
|
const item = source || {};
|
||||||
const label = String(item.label || "").trim();
|
const label = String(item.label || "").trim();
|
||||||
|
|
@ -392,6 +554,7 @@ export function RequestWorkspace({
|
||||||
field_type: fieldType,
|
field_type: fieldType,
|
||||||
document_name: String(item.document_name || "").trim(),
|
document_name: String(item.document_name || "").trim(),
|
||||||
value_text: item.value_text == null ? "" : String(item.value_text),
|
value_text: item.value_text == null ? "" : String(item.value_text),
|
||||||
|
value_file: item.value_file || null,
|
||||||
is_filled: Boolean(item.is_filled),
|
is_filled: Boolean(item.is_filled),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -821,15 +984,7 @@ export function RequestWorkspace({
|
||||||
message_id: currentMessageId,
|
message_id: currentMessageId,
|
||||||
items: payloadItems,
|
items: payloadItems,
|
||||||
});
|
});
|
||||||
setClientDataModal((prev) => ({
|
closeClientDataModal();
|
||||||
...prev,
|
|
||||||
saving: false,
|
|
||||||
status: "Данные сохранены.",
|
|
||||||
items: (prev.items || []).map((item) => ({
|
|
||||||
...item,
|
|
||||||
pendingFile: null,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setClientDataModal((prev) => ({
|
setClientDataModal((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -956,6 +1111,20 @@ export function RequestWorkspace({
|
||||||
chatTimelineItems.push(entry);
|
chatTimelineItems.push(entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatTab !== "chat") return;
|
||||||
|
const listNode = chatListRef.current;
|
||||||
|
if (!listNode) return;
|
||||||
|
const cursor = String(localActivityCursor || "");
|
||||||
|
if (!cursor || cursor === lastAutoScrollCursorRef.current) return;
|
||||||
|
lastAutoScrollCursorRef.current = cursor;
|
||||||
|
const raf = window.requestAnimationFrame(() => {
|
||||||
|
if (!chatListRef.current) return;
|
||||||
|
chatListRef.current.scrollTop = chatListRef.current.scrollHeight;
|
||||||
|
});
|
||||||
|
return () => window.cancelAnimationFrame(raf);
|
||||||
|
}, [chatTab, localActivityCursor]);
|
||||||
|
|
||||||
const routeNodes =
|
const routeNodes =
|
||||||
Array.isArray(statusRouteNodes) && statusRouteNodes.length
|
Array.isArray(statusRouteNodes) && statusRouteNodes.length
|
||||||
? statusRouteNodes
|
? statusRouteNodes
|
||||||
|
|
@ -972,7 +1141,7 @@ export function RequestWorkspace({
|
||||||
if (!items.length) return <p className="chat-message-text">Запрос</p>;
|
if (!items.length) return <p className="chat-message-text">Запрос</p>;
|
||||||
if (allFilled) {
|
if (allFilled) {
|
||||||
const fileOnly = items.length === 1 && String(items[0]?.field_type || "").toLowerCase() === "file";
|
const fileOnly = items.length === 1 && String(items[0]?.field_type || "").toLowerCase() === "file";
|
||||||
return <p className="chat-message-text chat-request-data-collapsed">{fileOnly ? "Файл" : "Запрос"}</p>;
|
return <p className="chat-message-text chat-request-data-collapsed">{fileOnly ? "Файл" : "Заполнен"}</p>;
|
||||||
}
|
}
|
||||||
const visibleItems = items.slice(0, 7);
|
const visibleItems = items.slice(0, 7);
|
||||||
const hiddenCount = Math.max(0, items.length - visibleItems.length);
|
const hiddenCount = Math.max(0, items.length - visibleItems.length);
|
||||||
|
|
@ -1014,6 +1183,13 @@ export function RequestWorkspace({
|
||||||
return text || "Не заполнено";
|
return text || "Не заполнено";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dataRequestProgress = useMemo(() => {
|
||||||
|
const rows = Array.isArray(dataRequestModal.rows) ? dataRequestModal.rows : [];
|
||||||
|
const total = rows.length;
|
||||||
|
const filled = rows.filter((rowItem) => Boolean(rowItem?.is_filled || String(rowItem?.value_text || "").trim())).length;
|
||||||
|
return { total, filled };
|
||||||
|
}, [dataRequestModal.rows]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="block">
|
<div className="block">
|
||||||
<div className="request-workspace-layout">
|
<div className="request-workspace-layout">
|
||||||
|
|
@ -1177,6 +1353,10 @@ export function RequestWorkspace({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="request-chat-live-row" aria-live="polite">
|
||||||
|
<span className={"chat-live-dot" + (liveMode === "degraded" ? " degraded" : "")} />
|
||||||
|
<span className="request-chat-live-text">{typingHintText || (liveMode === "degraded" ? "Связь нестабильна, включен backoff" : "Онлайн")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
id={idMap.fileInput}
|
id={idMap.fileInput}
|
||||||
|
|
@ -1190,7 +1370,7 @@ export function RequestWorkspace({
|
||||||
|
|
||||||
{chatTab === "chat" ? (
|
{chatTab === "chat" ? (
|
||||||
<>
|
<>
|
||||||
<ul className="simple-list request-modal-list request-chat-list" id={idMap.messagesList}>
|
<ul className="simple-list request-modal-list request-chat-list" id={idMap.messagesList} ref={chatListRef}>
|
||||||
{chatTimelineItems.length ? (
|
{chatTimelineItems.length ? (
|
||||||
chatTimelineItems.map((entry) =>
|
chatTimelineItems.map((entry) =>
|
||||||
entry.type === "date" ? (
|
entry.type === "date" ? (
|
||||||
|
|
@ -1327,6 +1507,8 @@ export function RequestWorkspace({
|
||||||
placeholder={messagePlaceholder}
|
placeholder={messagePlaceholder}
|
||||||
value={messageDraft}
|
value={messageDraft}
|
||||||
onChange={onMessageChange}
|
onChange={onMessageChange}
|
||||||
|
onFocus={() => setComposerFocused(true)}
|
||||||
|
onBlur={() => setComposerFocused(false)}
|
||||||
disabled={loading || fileUploading}
|
disabled={loading || fileUploading}
|
||||||
/>
|
/>
|
||||||
<div className="request-drop-hint muted">Перетащите файлы сюда или прикрепите скрепкой</div>
|
<div className="request-drop-hint muted">Перетащите файлы сюда или прикрепите скрепкой</div>
|
||||||
|
|
@ -1365,17 +1547,6 @@ export function RequestWorkspace({
|
||||||
Запросить
|
Запросить
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{canFillRequestData && idMap.fileUploadButton ? (
|
|
||||||
<button
|
|
||||||
className="btn secondary btn-sm"
|
|
||||||
type="button"
|
|
||||||
id={idMap.fileUploadButton}
|
|
||||||
onClick={onSendMessage}
|
|
||||||
disabled={loading || fileUploading || !hasPendingFiles}
|
|
||||||
>
|
|
||||||
Загрузить файл
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
<button
|
||||||
className="icon-btn file-action-btn composer-attach-btn"
|
className="icon-btn file-action-btn composer-attach-btn"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1956,6 +2127,13 @@ export function RequestWorkspace({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{dataRequestModal.templateStatus ? <div className="status ok">{dataRequestModal.templateStatus}</div> : null}
|
{dataRequestModal.templateStatus ? <div className="status ok">{dataRequestModal.templateStatus}</div> : null}
|
||||||
|
{canRequestData && dataRequestModal.messageId ? (
|
||||||
|
<div className="request-data-progress-line">
|
||||||
|
<span className="request-data-progress-chip">
|
||||||
|
{"Заполнено клиентом: " + String(dataRequestProgress.filled) + " / " + String(dataRequestProgress.total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="request-data-modal-grid">
|
<div className="request-data-modal-grid">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
|
@ -2122,6 +2300,31 @@ export function RequestWorkspace({
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{canRequestData && (rowItem?.is_filled || String(rowItem?.value_text || "").trim()) ? (
|
||||||
|
<div className="request-data-row-client-value">
|
||||||
|
<span className="request-data-row-client-label">Заполнено клиентом:</span>
|
||||||
|
{String(rowItem?.field_type || "").toLowerCase() === "file" ? (
|
||||||
|
rowItem?.value_file && rowItem.value_file.download_url ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-message-file-chip"
|
||||||
|
onClick={() => openAttachmentFromMessage(rowItem.value_file)}
|
||||||
|
>
|
||||||
|
<span className="chat-message-file-icon" aria-hidden="true">📎</span>
|
||||||
|
<span className="chat-message-file-name">{String(rowItem.value_file.file_name || "Файл")}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="muted">Файл добавлен</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="request-data-row-client-text">
|
||||||
|
{String(rowItem?.field_type || "").toLowerCase() === "date"
|
||||||
|
? fmtDateOnly(rowItem?.value_text)
|
||||||
|
: String(rowItem?.value_text || "").trim().slice(0, 140)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,32 @@ export function useRequestWorkspace(options) {
|
||||||
setRequestModal((prev) => ({ ...prev, pendingStatusChangePreset: null }));
|
setRequestModal((prev) => ({ ...prev, pendingStatusChangePreset: null }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const probeRequestLive = useCallback(
|
||||||
|
async ({ cursor } = {}) => {
|
||||||
|
const requestId = requestModal.requestId;
|
||||||
|
if (!api || !requestId) return { has_updates: false, typing: [], cursor: null };
|
||||||
|
const query = cursor ? "?cursor=" + encodeURIComponent(String(cursor)) : "";
|
||||||
|
const payload = await api("/api/admin/chat/requests/" + requestId + "/live" + query);
|
||||||
|
if (payload && payload.has_updates) {
|
||||||
|
await loadRequestModalData(requestId, { showLoading: false });
|
||||||
|
}
|
||||||
|
return payload || { has_updates: false, typing: [], cursor: null };
|
||||||
|
},
|
||||||
|
[api, loadRequestModalData, requestModal.requestId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setRequestTyping = useCallback(
|
||||||
|
async ({ typing } = {}) => {
|
||||||
|
const requestId = requestModal.requestId;
|
||||||
|
if (!api || !requestId) return { status: "skipped", typing: false };
|
||||||
|
return api("/api/admin/chat/requests/" + requestId + "/typing", {
|
||||||
|
method: "POST",
|
||||||
|
body: { typing: Boolean(typing) },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[api, requestModal.requestId]
|
||||||
|
);
|
||||||
|
|
||||||
const submitRequestStatusChange = useCallback(
|
const submitRequestStatusChange = useCallback(
|
||||||
async ({ requestId, statusCode, importantDateAt, comment, files } = {}) => {
|
async ({ requestId, statusCode, importantDateAt, comment, files } = {}) => {
|
||||||
if (!api) throw new Error("API недоступен");
|
if (!api) throw new Error("API недоступен");
|
||||||
|
|
@ -425,6 +451,8 @@ export function useRequestWorkspace(options) {
|
||||||
clearPendingStatusChangePreset,
|
clearPendingStatusChangePreset,
|
||||||
submitRequestStatusChange,
|
submitRequestStatusChange,
|
||||||
submitRequestModalMessage,
|
submitRequestModalMessage,
|
||||||
|
probeRequestLive,
|
||||||
|
setRequestTyping,
|
||||||
loadRequestDataTemplates,
|
loadRequestDataTemplates,
|
||||||
loadRequestDataBatch,
|
loadRequestDataBatch,
|
||||||
loadRequestDataTemplateDetails,
|
loadRequestDataTemplateDetails,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,6 @@
|
||||||
<div id="client-root"></div>
|
<div id="client-root"></div>
|
||||||
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
|
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
|
||||||
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
|
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
|
||||||
<script src="/client.js?v=20260227-01"></script>
|
<script src="/client.js?v=20260227-02"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
3273
app/web/client.js
3273
app/web/client.js
File diff suppressed because it is too large
Load diff
|
|
@ -621,6 +621,41 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut
|
||||||
[activeTrack, apiJson, loadRequestWorkspace]
|
[activeTrack, apiJson, loadRequestWorkspace]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const probeLiveState = useCallback(
|
||||||
|
async ({ cursor } = {}) => {
|
||||||
|
const track = String(activeTrack || "").trim();
|
||||||
|
if (!track) return { has_updates: false, typing: [], cursor: null };
|
||||||
|
const query = cursor ? "?cursor=" + encodeURIComponent(String(cursor)) : "";
|
||||||
|
const payload = await apiJson(
|
||||||
|
"/api/public/chat/requests/" + encodeURIComponent(track) + "/live" + query,
|
||||||
|
null,
|
||||||
|
"Не удалось получить live-обновления чата"
|
||||||
|
);
|
||||||
|
if (payload && payload.has_updates) {
|
||||||
|
await loadRequestWorkspace(track, false);
|
||||||
|
}
|
||||||
|
return payload || { has_updates: false, typing: [], cursor: null };
|
||||||
|
},
|
||||||
|
[activeTrack, apiJson, loadRequestWorkspace]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTypingSignal = useCallback(
|
||||||
|
async ({ typing } = {}) => {
|
||||||
|
const track = String(activeTrack || "").trim();
|
||||||
|
if (!track) return { status: "skipped", typing: false };
|
||||||
|
return apiJson(
|
||||||
|
"/api/public/chat/requests/" + encodeURIComponent(track) + "/typing",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ typing: Boolean(typing) }),
|
||||||
|
},
|
||||||
|
"Не удалось обновить статус набора"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[activeTrack, apiJson]
|
||||||
|
);
|
||||||
|
|
||||||
const openServiceRequestModal = useCallback((type) => {
|
const openServiceRequestModal = useCallback((type) => {
|
||||||
const normalized = String(type || "").trim().toUpperCase();
|
const normalized = String(type || "").trim().toUpperCase();
|
||||||
if (!normalized) return;
|
if (!normalized) return;
|
||||||
|
|
@ -797,6 +832,8 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut
|
||||||
onSaveRequestDataValues={saveRequestDataValues}
|
onSaveRequestDataValues={saveRequestDataValues}
|
||||||
onUploadRequestAttachment={uploadPublicRequestAttachment}
|
onUploadRequestAttachment={uploadPublicRequestAttachment}
|
||||||
onChangeStatus={() => Promise.resolve(null)}
|
onChangeStatus={() => Promise.resolve(null)}
|
||||||
|
onLiveProbe={probeLiveState}
|
||||||
|
onTypingSignal={setTypingSignal}
|
||||||
AttachmentPreviewModalComponent={AttachmentPreviewModal}
|
AttachmentPreviewModalComponent={AttachmentPreviewModal}
|
||||||
StatusLineComponent={StatusLine}
|
StatusLineComponent={StatusLine}
|
||||||
domIds={{
|
domIds={{
|
||||||
|
|
@ -805,7 +842,6 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut
|
||||||
messageBody: "cabinet-chat-body",
|
messageBody: "cabinet-chat-body",
|
||||||
sendButton: "cabinet-chat-send",
|
sendButton: "cabinet-chat-send",
|
||||||
fileInput: "cabinet-file-input",
|
fileInput: "cabinet-file-input",
|
||||||
fileUploadButton: "cabinet-file-upload",
|
|
||||||
dataRequestOverlay: "data-request-overlay",
|
dataRequestOverlay: "data-request-overlay",
|
||||||
dataRequestItems: "data-request-items",
|
dataRequestItems: "data-request-items",
|
||||||
dataRequestStatus: "data-request-status",
|
dataRequestStatus: "data-request-status",
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/landing.js"></script>
|
<script src="/landing.js?v=20260227-03"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,10 @@
|
||||||
});
|
});
|
||||||
const data = await parseJsonSafe(response);
|
const data = await parseJsonSafe(response);
|
||||||
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить OTP"));
|
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить OTP"));
|
||||||
|
const debugCode = String(data?.sms_response?.debug_code || "").trim();
|
||||||
|
if (debugCode) {
|
||||||
|
console.info("[OTP DEV] VIEW_REQUEST code:", debugCode);
|
||||||
|
}
|
||||||
setStatus(accessStatus, "Код отправлен. Проверьте SMS.", "ok");
|
setStatus(accessStatus, "Код отправлен. Проверьте SMS.", "ok");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(accessStatus, error?.message || "Не удалось отправить OTP", "error");
|
setStatus(accessStatus, error?.message || "Не удалось отправить OTP", "error");
|
||||||
|
|
@ -443,6 +447,10 @@
|
||||||
});
|
});
|
||||||
const otpSendData = await parseJsonSafe(otpSend);
|
const otpSendData = await parseJsonSafe(otpSend);
|
||||||
if (!otpSend.ok) throw new Error(apiErrorDetail(otpSendData, "Не удалось отправить OTP"));
|
if (!otpSend.ok) throw new Error(apiErrorDetail(otpSendData, "Не удалось отправить OTP"));
|
||||||
|
const debugCode = String(otpSendData?.sms_response?.debug_code || "").trim();
|
||||||
|
if (debugCode) {
|
||||||
|
console.info("[OTP DEV] CREATE_REQUEST code:", debugCode);
|
||||||
|
}
|
||||||
|
|
||||||
const isMocked = Boolean(otpSendData?.sms_response?.mocked) || String(otpSendData?.sms_response?.provider || "") === "mock_sms";
|
const isMocked = Boolean(otpSendData?.sms_response?.mocked) || String(otpSendData?.sms_response?.provider || "") === "mock_sms";
|
||||||
const code = await requestOtpCode(
|
const code = await requestOtpCode(
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -300,7 +300,7 @@ async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E fil
|
||||||
mimeType,
|
mimeType,
|
||||||
buffer,
|
buffer,
|
||||||
});
|
});
|
||||||
await page.locator("#cabinet-file-upload").click();
|
await page.locator("#cabinet-chat-send").click();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(page.locator("#client-page-status")).toContainText("Файл загружен.", { timeout: 20_000 });
|
await expect(page.locator("#client-page-status")).toContainText("Файл загружен.", { timeout: 20_000 });
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
from tests.admin.base import * # noqa: F401,F403
|
from tests.admin.base import * # noqa: F401,F403
|
||||||
|
from app.services.chat_presence import clear_presence_for_tests
|
||||||
|
|
||||||
|
|
||||||
class AdminLawyerChatTests(AdminUniversalCrudBase):
|
class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
clear_presence_for_tests()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
clear_presence_for_tests()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
def test_lawyer_permissions_and_request_crud(self):
|
def test_lawyer_permissions_and_request_crud(self):
|
||||||
lawyer_headers = self._auth_headers("LAWYER")
|
lawyer_headers = self._auth_headers("LAWYER")
|
||||||
|
|
||||||
|
|
@ -417,3 +426,103 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
self.assertEqual(admin_create.status_code, 201)
|
self.assertEqual(admin_create.status_code, 201)
|
||||||
self.assertEqual(admin_create.json()["author_type"], "SYSTEM")
|
self.assertEqual(admin_create.json()["author_type"], "SYSTEM")
|
||||||
|
|
||||||
|
def test_admin_chat_live_and_typing_endpoints_follow_rbac(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer_self = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Live Свой",
|
||||||
|
email="lawyer.live.self@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer_other = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Live Чужой",
|
||||||
|
email="lawyer.live.other@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([lawyer_self, lawyer_other])
|
||||||
|
db.flush()
|
||||||
|
self_id = str(lawyer_self.id)
|
||||||
|
other_id = str(lawyer_other.id)
|
||||||
|
|
||||||
|
own = Request(
|
||||||
|
track_number="TRK-CHAT-LIVE-OWN",
|
||||||
|
client_name="Клиент Live Свой",
|
||||||
|
client_phone="+79995550101",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
description="own",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=self_id,
|
||||||
|
)
|
||||||
|
foreign = Request(
|
||||||
|
track_number="TRK-CHAT-LIVE-FOREIGN",
|
||||||
|
client_name="Клиент Live Чужой",
|
||||||
|
client_phone="+79995550102",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
description="foreign",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=other_id,
|
||||||
|
)
|
||||||
|
unassigned = Request(
|
||||||
|
track_number="TRK-CHAT-LIVE-UNASSIGNED",
|
||||||
|
client_name="Клиент Live Без назначения",
|
||||||
|
client_phone="+79995550103",
|
||||||
|
status_code="NEW",
|
||||||
|
description="unassigned",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=None,
|
||||||
|
)
|
||||||
|
db.add_all([own, foreign, unassigned])
|
||||||
|
db.flush()
|
||||||
|
db.add(Message(request_id=own.id, author_type="CLIENT", author_name="Клиент", body="live start"))
|
||||||
|
db.commit()
|
||||||
|
own_id = str(own.id)
|
||||||
|
foreign_id = str(foreign.id)
|
||||||
|
unassigned_id = str(unassigned.id)
|
||||||
|
|
||||||
|
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.live.self@example.com", sub=self_id)
|
||||||
|
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)
|
||||||
|
self.assertEqual(own_live.status_code, 200)
|
||||||
|
own_cursor = str(own_live.json().get("cursor") or "")
|
||||||
|
own_live_no_delta = self.client.get(
|
||||||
|
f"/api/admin/chat/requests/{own_id}/live",
|
||||||
|
headers=lawyer_headers,
|
||||||
|
params={"cursor": own_cursor},
|
||||||
|
)
|
||||||
|
self.assertEqual(own_live_no_delta.status_code, 200)
|
||||||
|
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)
|
||||||
|
self.assertEqual(foreign_live.status_code, 403)
|
||||||
|
|
||||||
|
own_typing = self.client.post(
|
||||||
|
f"/api/admin/chat/requests/{own_id}/typing",
|
||||||
|
headers=lawyer_headers,
|
||||||
|
json={"typing": True},
|
||||||
|
)
|
||||||
|
self.assertEqual(own_typing.status_code, 200)
|
||||||
|
self.assertTrue(bool(own_typing.json().get("typing")))
|
||||||
|
|
||||||
|
unassigned_typing = self.client.post(
|
||||||
|
f"/api/admin/chat/requests/{unassigned_id}/typing",
|
||||||
|
headers=lawyer_headers,
|
||||||
|
json={"typing": True},
|
||||||
|
)
|
||||||
|
self.assertEqual(unassigned_typing.status_code, 403)
|
||||||
|
|
||||||
|
admin_typing = self.client.post(
|
||||||
|
f"/api/admin/chat/requests/{own_id}/typing",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"typing": True},
|
||||||
|
)
|
||||||
|
self.assertEqual(admin_typing.status_code, 200)
|
||||||
|
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)
|
||||||
|
self.assertEqual(own_live_with_typing.status_code, 200)
|
||||||
|
typing_rows = own_live_with_typing.json().get("typing") or []
|
||||||
|
self.assertTrue(any(str(item.get("actor_role")) == "ADMIN" for item in typing_rows))
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from app.models.notification import Notification
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.request_data_requirement import RequestDataRequirement
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
|
from app.services.chat_presence import clear_presence_for_tests, set_typing_presence
|
||||||
|
|
||||||
|
|
||||||
class _FakeBody:
|
class _FakeBody:
|
||||||
|
|
@ -85,6 +86,7 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
cls.engine.dispose()
|
cls.engine.dispose()
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
clear_presence_for_tests()
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
db.execute(delete(Notification))
|
db.execute(delete(Notification))
|
||||||
db.execute(delete(StatusHistory))
|
db.execute(delete(StatusHistory))
|
||||||
|
|
@ -107,6 +109,7 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.client.close()
|
self.client.close()
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
clear_presence_for_tests()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _public_cookies(track_number: str) -> dict[str, str]:
|
def _public_cookies(track_number: str) -> dict[str, str]:
|
||||||
|
|
@ -244,6 +247,66 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
denied = self.client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER"))
|
denied = self.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_public_live_endpoint_and_typing_state(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-LIVE-001",
|
||||||
|
client_name="Клиент Live",
|
||||||
|
client_phone="+79997771234",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="NEW",
|
||||||
|
description="Проверка live",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
Message(
|
||||||
|
request_id=req.id,
|
||||||
|
author_type="LAWYER",
|
||||||
|
author_name="Юрист",
|
||||||
|
body="Первое сообщение",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
|
||||||
|
cookies = self._public_cookies("TRK-LIVE-001")
|
||||||
|
live_initial = self.client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies)
|
||||||
|
self.assertEqual(live_initial.status_code, 200)
|
||||||
|
live_body = live_initial.json()
|
||||||
|
self.assertTrue(bool(live_body.get("has_updates")))
|
||||||
|
self.assertTrue(bool(live_body.get("cursor")))
|
||||||
|
|
||||||
|
set_typing_presence(
|
||||||
|
request_key=request_id,
|
||||||
|
actor_key="LAWYER:test",
|
||||||
|
actor_label="Юрист Тест",
|
||||||
|
actor_role="LAWYER",
|
||||||
|
typing=True,
|
||||||
|
)
|
||||||
|
live_with_typing = self.client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies)
|
||||||
|
self.assertEqual(live_with_typing.status_code, 200)
|
||||||
|
typing_rows = live_with_typing.json().get("typing") or []
|
||||||
|
self.assertTrue(any(str(item.get("actor_label")) == "Юрист Тест" for item in typing_rows))
|
||||||
|
|
||||||
|
current_cursor = str(live_with_typing.json().get("cursor") or "")
|
||||||
|
live_no_delta = self.client.get(
|
||||||
|
"/api/public/chat/requests/TRK-LIVE-001/live",
|
||||||
|
params={"cursor": current_cursor},
|
||||||
|
cookies=cookies,
|
||||||
|
)
|
||||||
|
self.assertEqual(live_no_delta.status_code, 200)
|
||||||
|
self.assertFalse(bool(live_no_delta.json().get("has_updates")))
|
||||||
|
|
||||||
|
typing_on = self.client.post(
|
||||||
|
"/api/public/chat/requests/TRK-LIVE-001/typing",
|
||||||
|
cookies=cookies,
|
||||||
|
json={"typing": True},
|
||||||
|
)
|
||||||
|
self.assertEqual(typing_on.status_code, 200)
|
||||||
|
self.assertTrue(bool(typing_on.json().get("typing")))
|
||||||
|
|
||||||
def test_public_cabinet_respects_track_access(self):
|
def test_public_cabinet_respects_track_access(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
req = Request(
|
req = Request(
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ class SmsServiceTests(unittest.TestCase):
|
||||||
payload = send_otp_message(phone="+79990000000", code="111111", purpose="CREATE_REQUEST")
|
payload = send_otp_message(phone="+79990000000", code="111111", purpose="CREATE_REQUEST")
|
||||||
self.assertEqual(payload.get("provider"), "mock_sms")
|
self.assertEqual(payload.get("provider"), "mock_sms")
|
||||||
self.assertTrue(bool(payload.get("dev_mode")))
|
self.assertTrue(bool(payload.get("dev_mode")))
|
||||||
|
self.assertEqual(payload.get("debug_code"), "111111")
|
||||||
|
|
||||||
def test_unknown_provider_raises(self):
|
def test_unknown_provider_raises(self):
|
||||||
settings.SMS_PROVIDER = "unknown"
|
settings.SMS_PROVIDER = "unknown"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue