diff --git a/app/pyproject.toml b/app/pyproject.toml
index 9fcd95be8..06cb91a78 100755
--- a/app/pyproject.toml
+++ b/app/pyproject.toml
@@ -11,6 +11,7 @@ requires-python = ">=3.11"
license = { text = "Proprietary" }
authors = [{ name = "Zhenyangtang" }]
dependencies = [
+ "cos-python-sdk-v5==1.9.44",
"httpx>=0.27.2,<1",
"keyring>=25.5,<26",
"opencc-python-reimplemented==0.1.7",
diff --git a/app/src/doctor_workstation/services/cos_upload.py b/app/src/doctor_workstation/services/cos_upload.py
new file mode 100644
index 000000000..ed4850569
--- /dev/null
+++ b/app/src/doctor_workstation/services/cos_upload.py
@@ -0,0 +1,223 @@
+"""COS video uploads using short-lived server credentials and the official SDK."""
+
+from __future__ import annotations
+
+import logging
+import re
+import time
+from collections.abc import Callable, Mapping
+from contextlib import suppress
+from datetime import datetime
+from pathlib import Path
+from threading import RLock
+from typing import Any
+from urllib.parse import unquote, urlsplit
+from uuid import uuid4
+
+from qcloud_cos import CosConfig, CosS3Client
+
+from doctor_workstation.core.errors import ApiProtocolError, ApiTransportError
+from doctor_workstation.services.api_client import ApiClient
+
+_VIDEO_EXTENSIONS = {
+ "wmv", "avi", "mpg", "mpeg", "3gp", "mov", "mp4", "m4v", "flv", "f4v", "rmvb", "mkv", "webm"
+}
+_MAX_VIDEO_SIZE = 2 * 1024 * 1024 * 1024
+
+
+def _positive_int(value: Any, field: str) -> int:
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
+ raise ApiProtocolError(f"COS 响应的 {field} 无效")
+ try:
+ result = int(value)
+ except ValueError:
+ raise ApiProtocolError(f"COS 响应的 {field} 无效") from None
+ if result <= 0:
+ raise ApiProtocolError(f"COS 响应的 {field} 无效")
+ return result
+
+
+def _string(data: Mapping[str, Any], field: str) -> str:
+ value = data.get(field)
+ if not isinstance(value, str) or not value or value != value.strip():
+ raise ApiProtocolError(f"COS 响应缺少有效的 {field}")
+ if any(ord(character) < 32 or ord(character) == 127 for character in value):
+ raise ApiProtocolError(f"COS 响应的 {field} 无效")
+ return value
+
+
+def _upload_key(credentials: Mapping[str, Any], extension: str) -> str:
+ prefix = _string(credentials, "key_prefix")
+ if not re.fullmatch(r"uploads/video/[0-9]{8}/", prefix):
+ raise ApiProtocolError("COS 视频上传目录无效")
+ try:
+ datetime.strptime(prefix.split("/")[2], "%Y%m%d")
+ except ValueError:
+ raise ApiProtocolError("COS 视频上传目录日期无效") from None
+
+ fixed_key = credentials.get("object_key", "")
+ if not isinstance(fixed_key, str):
+ raise ApiProtocolError("COS 视频对象 Key 无效")
+ if not fixed_key:
+ return f"{prefix}{int(time.time() * 1000)}-{uuid4().hex}.{extension}"
+ suffix = fixed_key.removeprefix(prefix)
+ if (
+ not fixed_key.startswith(prefix)
+ or len(fixed_key) > 1024
+ or not all(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", part) for part in suffix.split("/"))
+ or Path(suffix).suffix.lower() != f".{extension}"
+ ):
+ raise ApiProtocolError("COS 视频对象 Key 不在授权范围内")
+ return fixed_key
+
+
+def _confirmed_result(result: Any, key: str) -> dict[str, Any]:
+ if not isinstance(result, Mapping):
+ raise ApiProtocolError("COS 上传确认响应无效")
+ _positive_int(result.get("id"), "id")
+ address = result.get("uri") or result.get("url")
+ if not isinstance(address, str) or not address or address != address.strip():
+ raise ApiProtocolError("COS 上传确认未返回有效的视频地址")
+ try:
+ parsed = urlsplit(address)
+ # Accessing port also validates malformed/non-numeric authority ports.
+ _ = parsed.port
+ valid = (
+ parsed.scheme in {"https", "http"}
+ and bool(parsed.hostname)
+ and parsed.username is None
+ and parsed.password is None
+ and not parsed.fragment
+ and not any(
+ character.isspace() or ord(character) < 32 or ord(character) == 127
+ for character in address
+ )
+ and unquote(parsed.path).endswith("/" + key)
+ )
+ except ValueError:
+ valid = False
+ if not valid:
+ raise ApiProtocolError("COS 上传确认返回的视频地址无效")
+ return dict(result)
+
+
+def upload_video_to_cos(
+ client: ApiClient,
+ path: Path,
+ *,
+ mime_type: str,
+ on_progress: Callable[[int, int], None] | None = None,
+ on_processing: Callable[[], None] | None = None,
+) -> dict[str, Any]:
+ """Upload and confirm one video; the caller still owns call-record binding.
+
+ Progress stays below the full file size, including after confirmation. Only
+ the caller can report completion once the recording is attached. SDK worker
+ threads may invoke progress callbacks; callback failures are ignored.
+ """
+
+ source = path.expanduser().resolve()
+ if not source.is_file():
+ raise ValueError("视频文件不存在")
+ size = source.stat().st_size
+ extension = source.suffix.lower().removeprefix(".")
+ if size <= 0 or size > _MAX_VIDEO_SIZE:
+ raise ValueError("视频文件大小须大于 0 且不超过 2 GB")
+ if extension not in _VIDEO_EXTENSIONS:
+ raise ValueError("不支持的视频文件格式")
+ if (
+ not isinstance(mime_type, str)
+ or not re.fullmatch(r"video/[A-Za-z0-9!#$&^_.+-]+(?:;[^\r\n]*)?", mime_type)
+ or len(mime_type) > 120
+ or any(ord(character) < 32 or ord(character) == 127 for character in mime_type)
+ ):
+ raise ValueError("视频 MIME 类型无效")
+
+ last_progress = -1
+ progress_lock = RLock()
+
+ def report(sent: int, _total: int = size) -> None:
+ nonlocal last_progress
+ with progress_lock, suppress(Exception):
+ confirmed = max(0, min(int(sent), size - 1))
+ if confirmed > last_progress:
+ last_progress = confirmed
+ if on_progress is not None:
+ on_progress(confirmed, size)
+
+ report(0)
+ response = client.post("upload/ossCredentials", {"type": "video", "name": source.name})
+ if not isinstance(response, Mapping):
+ raise ApiProtocolError("COS 上传凭证响应无效")
+ if response.get("provider") != "qcloud" or response.get("fallback") is not False:
+ raise ApiProtocolError("服务器未启用 COS 直传,无法上传视频回放")
+ bucket = _string(response, "bucket")
+ region = _string(response, "region")
+ if len(bucket) > 63 or not re.fullmatch(r"[a-z0-9][a-z0-9-]{1,49}-[0-9]{5,20}", bucket):
+ raise ApiProtocolError("COS 存储桶无效")
+ if not re.fullmatch(r"[a-z]{2}-[a-z][a-z0-9]*(?:-[a-z0-9]+)*", region):
+ raise ApiProtocolError("COS 存储区域无效")
+ if size > _positive_int(response.get("max_size"), "max_size"):
+ raise ValueError("视频大小超过服务器允许的 COS 上传上限")
+ start = _positive_int(response.get("start_time"), "start_time")
+ expires = _positive_int(response.get("expired_time"), "expired_time")
+ now = time.time()
+ if start >= expires or expires <= now or start > now + 60:
+ raise ApiProtocolError("COS 上传凭证已过期或尚未生效,请重试")
+ temporary = response.get("credentials")
+ if not isinstance(temporary, Mapping):
+ raise ApiProtocolError("COS 响应缺少临时凭证")
+ secret_id = _string(temporary, "tmpSecretId")
+ secret_key = _string(temporary, "tmpSecretKey")
+ token = _string(temporary, "sessionToken")
+ key = _upload_key(response, extension)
+
+ # SDK debug logs include signing keys and request headers; even error logs
+ # may echo provider payloads. Keep these out of the application's debug log.
+ # The SDK installs a NullHandler on this logger; report only our safe error.
+ logging.getLogger("qcloud_cos").propagate = False
+ try:
+ config = CosConfig(
+ Region=region,
+ SecretId=secret_id,
+ SecretKey=secret_key,
+ Token=token,
+ Scheme="https",
+ Timeout=120,
+ VerifySSL=True,
+ )
+ # Never use returned host/CDN values as an SDK endpoint, and never send
+ # the application's admin token to COS. The SDK derives the COS host.
+ CosS3Client(config).upload_file(
+ Bucket=bucket,
+ Key=key,
+ LocalFilePath=str(source),
+ PartSize=1,
+ MAXThread=3,
+ ContentType=mime_type,
+ progress_callback=report,
+ )
+ except Exception:
+ raise ApiTransportError(
+ "COS 视频上传失败,请检查网络后重试。",
+ data={"provider": "cos", "operation": "upload"},
+ ) from None
+
+ # Small-file put_object does not call the SDK's progress callback.
+ report(size - 1)
+ if on_processing is not None:
+ with suppress(Exception):
+ on_processing()
+ result = client.post(
+ "upload/ossConfirm",
+ {
+ "type": "video",
+ "key": key,
+ "name": source.name,
+ "size": size,
+ "content_type": mime_type,
+ "cid": 0,
+ },
+ timeout=120.0,
+ )
+ return _confirmed_result(result, key)
diff --git a/app/src/doctor_workstation/services/mock_repository.py b/app/src/doctor_workstation/services/mock_repository.py
index 10e8cfea6..abdcd18ad 100755
--- a/app/src/doctor_workstation/services/mock_repository.py
+++ b/app/src/doctor_workstation/services/mock_repository.py
@@ -8,6 +8,7 @@ import re
import struct
import zlib
from collections.abc import Callable, Mapping, Sequence
+from contextlib import suppress
from copy import deepcopy
from datetime import date, datetime, timedelta
from os import PathLike
@@ -2885,6 +2886,7 @@ class DemoDoctorRepository:
call_record_id: int | None = None,
mime_type: str = "audio/webm",
on_progress: Callable[[int, int], None] | None = None,
+ on_processing: Callable[[], None] | None = None,
) -> dict[str, Any]:
"""Upload then attach a demo local-audio file using the production sequence."""
@@ -2892,6 +2894,9 @@ class DemoDoctorRepository:
size = source_path.stat().st_size if source_path.is_file() else 0
if on_progress is not None:
on_progress(0, size)
+ if on_processing is not None:
+ with suppress(Exception):
+ on_processing()
is_audio = source_path.suffix.lower() in {
".ogg",
".opus",
diff --git a/app/src/doctor_workstation/services/repository.py b/app/src/doctor_workstation/services/repository.py
index d6c5e3c5f..869af9cc3 100755
--- a/app/src/doctor_workstation/services/repository.py
+++ b/app/src/doctor_workstation/services/repository.py
@@ -36,6 +36,7 @@ from doctor_workstation.core.permissions import PermissionSet
from doctor_workstation.core.session import Session
from .api_client import ApiClient
+from .cos_upload import upload_video_to_cos
from .token_store import TokenStore
AuditAction = Literal["approve", "reject"]
@@ -694,6 +695,7 @@ class DoctorRepository(Protocol):
call_record_id: int | None = None,
mime_type: str = "audio/webm",
on_progress: Callable[[int, int], None] | None = None,
+ on_processing: Callable[[], None] | None = None,
) -> dict[str, Any]:
"""Upload a locally captured call recording to a real call record."""
@@ -2578,11 +2580,14 @@ class RemoteDoctorRepository:
call_record_id: int | None = None,
mime_type: str = "audio/webm",
on_progress: Callable[[int, int], None] | None = None,
+ on_processing: Callable[[], None] | None = None,
) -> dict[str, Any]:
- """Chunk-upload one local audio recording and attach it atomically.
+ """Upload replay videos directly to COS; retain the audio chunk contract.
- ``on_progress(sent_bytes, total_bytes)`` runs on the calling thread after
- every confirmed chunk; its failures never interrupt the upload.
+ ``on_progress(sent_bytes, total_bytes)`` may run on COS worker threads;
+ 100% requires a valid completion receipt and call-record binding.
+ ``on_processing`` marks confirmation/attachment after the video reaches
+ COS. Callback errors are ignored.
"""
if diagnosis_id <= 0:
@@ -2596,6 +2601,19 @@ class RemoteDoctorRepository:
if file_size <= 0:
raise ValueError("recording file must not be empty")
+ video_extensions = {
+ ".wmv", ".avi", ".mpg", ".mpeg", ".3gp", ".mov", ".mp4",
+ ".m4v", ".flv", ".f4v", ".rmvb", ".mkv",
+ }
+ if str(mime_type).lower().startswith("video/") or recording_path.suffix.lower() in video_extensions:
+ video_mime = str(mime_type).strip()
+ if not video_mime.lower().startswith("video/"):
+ video_mime = mimetypes.guess_type(recording_path.name)[0] or "video/mp4"
+ return self._upload_video_call_recording(
+ recording_path, diagnosis_id, call_record_id=call_record_id,
+ mime_type=video_mime, on_progress=on_progress, on_processing=on_processing,
+ )
+
chunk_size = 4 * 1024 * 1024
chunk_total = (file_size + chunk_size - 1) // chunk_size
upload_id = f"local_audio_{uuid4().hex}"
@@ -2644,15 +2662,47 @@ class RemoteDoctorRepository:
raise ApiProtocolError(
"uploadCallRecording returned a different call_record_id"
)
- report((chunk_index + 1) * chunk_size)
+ if chunk_index < chunk_total - 1:
+ report((chunk_index + 1) * chunk_size)
if final_result is None or not bool(final_result.get("completed")):
raise ApiProtocolError("uploadCallRecording did not complete the local audio upload")
file_url = str(final_result.get("file_url") or "").strip()
if not file_url:
raise ApiProtocolError("uploadCallRecording returned no COS file URL")
+ report(file_size)
return dict(final_result)
+ def _upload_video_call_recording(
+ self, path: Path, diagnosis_id: int, *, call_record_id: int | None,
+ mime_type: str, on_progress: Callable[[int, int], None] | None,
+ on_processing: Callable[[], None] | None,
+ ) -> dict[str, Any]:
+ uploaded = upload_video_to_cos(
+ self.client, path, mime_type=mime_type,
+ on_progress=on_progress, on_processing=on_processing,
+ )
+ file_url = str(uploaded.get("uri") or uploaded.get("url") or "").strip()
+ if not file_url:
+ raise ApiProtocolError("COS confirmation returned no file URL")
+ if call_record_id is None:
+ created = self.create_manual_call_record(diagnosis_id)
+ call_record_id = _to_int(created.get("id"), 0)
+ if call_record_id <= 0:
+ raise ApiProtocolError("createManualCallRecord returned no call_record_id")
+ self.attach_local_call_recording(
+ diagnosis_id, file_url, call_record_id=call_record_id,
+ )
+ if on_progress is not None:
+ with suppress(Exception):
+ size = path.stat().st_size
+ on_progress(size, size)
+ return {
+ "completed": True, "call_record_id": call_record_id,
+ "file_id": uploaded.get("id", 0), "file_url": file_url,
+ "media_kind": "video", "recording_status": 2,
+ }
+
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
) -> dict[str, Any]:
diff --git a/app/src/doctor_workstation/ui/diagnosis_index_widgets.py b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py
index a5a5e39f9..d97b109b5 100755
--- a/app/src/doctor_workstation/ui/diagnosis_index_widgets.py
+++ b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py
@@ -612,6 +612,7 @@ class DiagnosisTableModel(QAbstractTableModel):
self._checked_ids: set[int] = set()
self._hover_row = -1
self._sort_direction = ""
+ self._assistant_names: dict[int, str] = {}
def rowCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802
return 0 if parent.isValid() else len(self.rows)
@@ -645,6 +646,9 @@ class DiagnosisTableModel(QAbstractTableModel):
if self.record_id(row) in self._checked_ids
else Qt.CheckState.Unchecked
)
+ if column == 7 and role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole):
+ name = self.assistant_name(row)
+ return name or ("—" if role == Qt.ItemDataRole.DisplayRole else "")
if role == Qt.ItemDataRole.ToolTipRole:
return self._tooltip(row, column)
if role == Qt.ItemDataRole.TextAlignmentRole:
@@ -711,6 +715,30 @@ class DiagnosisTableModel(QAbstractTableModel):
def checked_records(self) -> list[Any]:
return [row for row in self.rows if self.record_id(row) in self._checked_ids]
+ def assistant_name(self, row: Any) -> str:
+ name = str(first_value(row, "assistant_name", default="")).strip()
+ if name:
+ return name
+ assistant_id = _as_int(first_value(row, "assistant_id", "assistant", default=0))
+ return self._assistant_names.get(assistant_id, "") if assistant_id > 0 else ""
+
+ def set_assistant_names(self, names: Mapping[int, str]) -> bool:
+ """Refresh names from the authorized picker without changing diagnosis records."""
+ normalized = {
+ _as_int(key): str(value).strip()
+ for key, value in names.items()
+ if _as_int(key) > 0 and value and str(value).strip()
+ }
+ if normalized == self._assistant_names:
+ return False
+ self._assistant_names = normalized
+ if self.rows:
+ self.dataChanged.emit(
+ self.index(0, 7), self.index(len(self.rows) - 1, 7),
+ [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole],
+ )
+ return True
+
def clear_checks(self) -> None:
if not self._checked_ids:
return
@@ -1269,11 +1297,15 @@ class _TechBlueDiagnosisDelegate(DiagnosisItemDelegate):
"""Approved consultation presentation, isolated from the patients table."""
@staticmethod
- def row_height(record: Any, widths: Sequence[int]) -> int:
+ def row_height(
+ record: Any, widths: Sequence[int], *, assistant_name: str | None = None,
+ ) -> int:
_entries, appointment_height = _blue_appointment_layout(record, widths[4])
heights = [72, 16 + appointment_height]
for column in (1, 2, 3, 6, 7):
text = DiagnosisTableModel._display(record, column)
+ if column == 7 and assistant_name is not None:
+ text = assistant_name or "—"
heights.append(18 + _blue_text_height(
text, widths[column] - 16, 13 if column == 6 else 14, medium=column == 2,
))
@@ -1287,7 +1319,10 @@ class _TechBlueDiagnosisDelegate(DiagnosisItemDelegate):
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802
model = index.model()
record = model.record(index.row()) if isinstance(model, DiagnosisTableModel) else None
- return QSize(option.rect.width(), self.row_height(record, _BLUE_COLUMN_WIDTHS))
+ name = model.assistant_name(record) if isinstance(model, DiagnosisTableModel) else None
+ return QSize(option.rect.width(), self.row_height(
+ record, _BLUE_COLUMN_WIDTHS, assistant_name=name,
+ ))
@staticmethod
def _text(
@@ -1850,6 +1885,11 @@ class DiagnosisTableHost(QFrame):
def selected_records(self) -> list[Any]:
return self.model.checked_records()
+ def set_assistant_names(self, names: Mapping[int, str]) -> None:
+ if self.model.set_assistant_names(names):
+ self._sync_row_heights()
+ self._schedule_fixed_height_sync()
+
def clear_checks(self) -> None:
self.model.clear_checks()
@@ -1898,7 +1938,8 @@ class DiagnosisTableHost(QFrame):
)
height = (
_TechBlueDiagnosisDelegate.row_height(
- record, [self.main.columnWidth(column) for column in range(10)]
+ record, [self.main.columnWidth(column) for column in range(10)],
+ assistant_name=self.model.assistant_name(record),
) if self.tech_blue else max(52, 12 + count * 42 + (18 if channel else 0))
)
self.main.setRowHeight(row, height)
diff --git a/app/src/doctor_workstation/ui/dialogs/diagnosis.py b/app/src/doctor_workstation/ui/dialogs/diagnosis.py
index 580153723..8f749273e 100755
--- a/app/src/doctor_workstation/ui/dialogs/diagnosis.py
+++ b/app/src/doctor_workstation/ui/dialogs/diagnosis.py
@@ -31,6 +31,7 @@ from PySide6.QtWidgets import (
QFrame,
QGridLayout,
QHBoxLayout,
+ QHeaderView,
QInputDialog,
QLabel,
QMessageBox,
@@ -48,6 +49,7 @@ from PySide6.QtWidgets import (
)
from ...core.appointment_modes import appointment_type_description, appointment_type_value
+from ...core.errors import ApiTimeoutError, ApiTransportError
from ...core.transcript_text import simplified_transcript
from ..design_system import clinical_stylesheet
from ..diagnosis_drawer import (
@@ -105,6 +107,7 @@ class _UploadProgressRelay(QObject):
# 用 object 传字节数:Qt 的 int 是 32 位,超过 2 GB 的视频会溢出。
progressed = Signal(object, object)
+ processing = Signal()
_PHONE_PERMISSION = "tcm.diagnosis/phonePlain"
@@ -975,6 +978,7 @@ class DiagnosisDialog(QDialog):
self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.readonly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.readonly_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
+ self.readonly_scroll.viewport().installEventFilter(self)
self.readonly_scroll.verticalScrollBar().setSingleStep(28)
content = QWidget()
self.readonly_content_layout = QVBoxLayout(content)
@@ -999,6 +1003,25 @@ class DiagnosisDialog(QDialog):
self.readonly_content_layout.addWidget(self.readonly_error)
self.readonly_patient_card = self._build_readonly_patient_card()
self.readonly_content_layout.addWidget(self.readonly_patient_card)
+ self.readonly_prescriptions_table = self._new_table(
+ "prescription", "DiagnosisReadonlyPrescriptionsTable"
+ )
+ # Keep the open action visible beside the editor; full prescription
+ # contents remain available through the existing detail dialog.
+ self.readonly_prescriptions_table.setColumnHidden(1, True)
+ self.readonly_prescriptions_table.setColumnHidden(3, True)
+ self.readonly_prescriptions_table.horizontalHeaderItem(0).setText("开方日期")
+ self.readonly_prescriptions_table.horizontalHeaderItem(2).setText("临床诊断")
+ for column, width in ((0, 100), (4, 70), (5, 84), (6, 64)):
+ self.readonly_prescriptions_table.setColumnWidth(column, width)
+ self.readonly_prescriptions_table.horizontalHeader().setSectionResizeMode(
+ 2, QHeaderView.ResizeMode.Stretch
+ )
+ self.readonly_prescriptions_table.setMinimumHeight(220)
+ self._add_readonly_section("prescription", "历史处方", self.readonly_prescriptions_table)
+ self.readonly_content_layout.setAlignment(
+ self._readonly_sections["prescription"], Qt.AlignmentFlag.AlignLeft
+ )
self.case_grid = CaseGrid()
self.readonly_content_layout.addWidget(self.case_grid)
@@ -1991,6 +2014,17 @@ class DiagnosisDialog(QDialog):
self._sync_host_geometry()
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt API
+ if (
+ hasattr(self, "readonly_scroll")
+ and watched is self.readonly_scroll.viewport()
+ and event.type() == QEvent.Type.Resize
+ and "prescription" in self._readonly_sections
+ ):
+ # Other patient cards can have a wider minimum size; the history
+ # actions must still fit inside the visible side pane.
+ self._readonly_sections["prescription"].setFixedWidth(
+ max(0, self.readonly_scroll.viewport().width() - 32)
+ )
if watched is self._owner and event.type() in {
QEvent.Type.Resize,
QEvent.Type.Move,
@@ -2745,7 +2779,7 @@ class DiagnosisDialog(QDialog):
def _load_visible_readonly_sections(self) -> None:
permission_map = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
- for key in ("notes", "daily", "orders", "video", "chat", "assign", "appointment"):
+ for key in ("prescription", "notes", "daily", "orders", "video", "chat", "assign", "appointment"):
if self._tab_allowed(permission_map.get(key, ())):
self._ensure_tab_loaded(key)
@@ -3187,6 +3221,27 @@ class DiagnosisDialog(QDialog):
"offset": self._offset_generation,
}[kind]
if generation == current and diagnosis_id == self._diagnosis_id:
+ error_data = getattr(error, "data", None)
+ endpoint = error_data.get("endpoint") if isinstance(error_data, Mapping) else None
+ if (
+ kind == "media" and isinstance(error, ApiTransportError)
+ and isinstance(error_data, Mapping) and error_data.get("provider") == "cos"
+ ):
+ self._show_message(str(error), "danger")
+ return
+ if (
+ kind == "media" and isinstance(error, ApiTimeoutError)
+ and getattr(self, "_video_upload_relay", None) is not None
+ and endpoint in {
+ "upload/ossConfirm", "tcm.diagnosis/createManualCallRecord",
+ "tcm.diagnosis/attachLocalCallRecording",
+ }
+ ):
+ self._show_message(
+ "视频已上传,等待保存回放记录超时。请稍后重新打开本页核对结果,避免立即重复上传。",
+ "warning",
+ )
+ return
self._show_message(friendly_error(error), "danger")
def _add_doctor_note(self) -> None:
@@ -3365,6 +3420,9 @@ class DiagnosisDialog(QDialog):
def _upload_call_recording(self, call_record_id: int | None = None) -> None:
if not self._editable or not self._can_video_upload:
return
+ if getattr(self, "_video_upload_relay", None) is not None:
+ self._show_message("当前回放正在上传,请等待完成。", "info")
+ return
if call_record_id is not None and call_record_id <= 0:
self._show_message("通话记录 ID 无效,无法追加回放。", "warning")
return
@@ -3381,8 +3439,16 @@ class DiagnosisDialog(QDialog):
if not mime_type.startswith("video/"):
mime_type = "video/mp4"
file_name = Path(path).name
+ diagnosis_id = self._diagnosis_id
relay = _UploadProgressRelay(self)
- relay.progressed.connect(lambda sent, total: self._video_upload_progressed(file_name, sent, total))
+ relay.progressed.connect(
+ lambda sent, total: self._video_upload_progressed(file_name, sent, total)
+ if getattr(self, "_video_upload_relay", None) is relay else None
+ )
+ relay.processing.connect(
+ lambda: self._video_upload_processing(file_name)
+ if getattr(self, "_video_upload_relay", None) is relay else None
+ )
self._video_upload_relay = relay
self._video_upload_progressed(file_name, 0, 0)
self._run_media_mutation(
@@ -3390,10 +3456,11 @@ class DiagnosisDialog(QDialog):
self.repository,
"upload_call_recording",
path=path,
- diagnosis_id=self._diagnosis_id,
+ diagnosis_id=diagnosis_id,
call_record_id=call_record_id,
mime_type=mime_type,
on_progress=relay.progressed.emit,
+ on_processing=relay.processing.emit,
),
"视频回放已追加到指定通话记录。"
if call_record_id is not None
@@ -3416,13 +3483,22 @@ class DiagnosisDialog(QDialog):
percent = int(ratio * 100)
mb = 1024 * 1024
if sent >= total:
- text = f"「{file_name}」已上传完成({total / mb:.1f} MB),正在生成通话记录…"
+ text = f"「{file_name}」已上传并保存({total / mb:.1f} MB),正在刷新回放列表…"
else:
text = f"正在上传「{file_name}」 {percent}%({sent / mb:.1f} / {total / mb:.1f} MB),请勿关闭窗口"
self.video_upload_status.setText(text)
self.video_upload_button.setText(f"上传中 {percent}%")
self._show_message(text, "info")
+ def _video_upload_processing(self, file_name: str) -> None:
+ self.video_upload_frame.show()
+ self.video_upload_bar.setRange(0, 0)
+ self.video_upload_button.setEnabled(False)
+ self.video_upload_button.setText("正在保存回放…")
+ text = f"「{file_name}」已上传,正在保存回放记录,请勿关闭窗口…"
+ self.video_upload_status.setText(text)
+ self._show_message(text, "info")
+
def _video_upload_finished(self, relay: QObject) -> None:
if getattr(self, "_video_upload_relay", None) is not relay:
return
@@ -3809,12 +3885,15 @@ class DiagnosisDialog(QDialog):
first_value(row, "doctor_name"),
"已作废"
if voided
- else first_value(row, "status_text", "audit_status_text", default="正常"),
+ else first_value(
+ row, "status_text", "audit_status_text",
+ default=_audit_text(first_value(row, "audit_status", default=None)),
+ ),
"",
)
)
for table in self._table_registry["prescription"]:
- table.set_empty_text("暂无病历记录,开方后会自动显示。")
+ table.set_empty_text("暂无历史处方,开方后会自动显示。")
table.set_matrix(matrix)
self._semanticize(table, 5)
for row_index, row in enumerate(rows):
@@ -3831,6 +3910,7 @@ class DiagnosisDialog(QDialog):
voided = _truthy(first_value(row, "void_status", "is_void", default=False))
can_edit = (
self._editable
+ and table is not self.readonly_prescriptions_table
and self._can_edit_prescription
and (not approved or voided)
)
@@ -3914,6 +3994,10 @@ class DiagnosisDialog(QDialog):
if actual_id != prescription_id:
self._show_message("服务端处方与当前行不一致,已停止打开。", "danger")
return
+ detail_diagnosis_id = _int(first_value(prescription, "diagnosis_id", default=0), 0)
+ if self._diagnosis_id > 0 and detail_diagnosis_id != self._diagnosis_id:
+ self._show_message("该处方不属于当前诊单,已停止打开。", "danger")
+ return
self._clear_message()
if edit:
self._open_prescription_edit_dialog(prescription, prescription_id)
diff --git a/app/src/doctor_workstation/ui/pages/consultations.py b/app/src/doctor_workstation/ui/pages/consultations.py
index 87c46b86e..e63621d7c 100755
--- a/app/src/doctor_workstation/ui/pages/consultations.py
+++ b/app/src/doctor_workstation/ui/pages/consultations.py
@@ -40,6 +40,7 @@ from PySide6.QtWidgets import (
from ...core import UserProfile
from ...core.appointment_modes import appointment_type_value, can_appointment_video
+from ...core.errors import ApiBusinessError
from .. import icons
from ..consultations_style import consultations_stylesheet
from ..diagnosis_index_widgets import (
@@ -1643,13 +1644,6 @@ class ConsultationsPage(QWidget):
return combo.currentText().strip()
return "" if data is None else data
- def _assistant_cell(self, _value: Any, row: Any) -> str:
- name = first_value(row, "assistant_name", default="")
- if name:
- return display_text(name)
- assistant_id = _as_int(first_value(row, "assistant_id", "assistant", default=0))
- return self._assistant_names.get(assistant_id, "—")
-
def _custom_date_changed(self, value: QDate) -> None:
if value == _OPTIONAL_DATE_MINIMUM:
return
@@ -2235,9 +2229,15 @@ class ConsultationsPage(QWidget):
self._departments_loading = False
self._departments_loaded = False
self.department_combo.clear()
- self.department_combo.addItem("部门加载失败", "")
+ permission_denied = isinstance(error, ApiBusinessError) and any(
+ text in str(error) for text in ("权限不足", "无权限", "无权")
+ )
+ self.department_combo.addItem("暂无部门筛选权限" if permission_denied else "部门加载失败", "")
self.department_combo.setEnabled(False)
- message = f"部门加载失败:{friendly_error(error)}。点击重试。"
+ message = (
+ "当前账号无权读取部门,请联系管理员检查部门筛选权限,处理后点击重试。"
+ if permission_denied else f"部门加载失败:{friendly_error(error)}。点击重试。"
+ )
self.department_combo.setToolTip(message)
self.department_retry_button.setToolTip(message)
self.department_retry_button.show()
@@ -2320,11 +2320,12 @@ class ConsultationsPage(QWidget):
combo.setEditText(str(current))
self._assistant_names = {
_as_int(first_value(row, "id", "value")): display_text(
- first_value(row, "name", "label")
+ first_value(row, "name", "label"), ""
)
for row in options.get("assistants", [])
if _as_int(first_value(row, "id", "value")) > 0
}
+ self.table_host.set_assistant_names(self._assistant_names)
def _checked_changed(self, count: int) -> None:
self.selected_label.setText(f"已选 {count} 条")
@@ -2418,6 +2419,7 @@ class ConsultationsPage(QWidget):
}
options = {key: value for key, value in options.items() if value}
self._assistant_names.update(options)
+ self.table_host.set_assistant_names(self._assistant_names)
self._show_assistant_picker(records, options)
def _show_assistant_picker(
diff --git a/app/src/doctor_workstation/ui/pages/prescriptions.py b/app/src/doctor_workstation/ui/pages/prescriptions.py
index 5a0a65f83..069a3769a 100755
--- a/app/src/doctor_workstation/ui/pages/prescriptions.py
+++ b/app/src/doctor_workstation/ui/pages/prescriptions.py
@@ -8,12 +8,24 @@ from html import escape
from math import ceil
from typing import Any
-from PySide6.QtCore import QDateTime, QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
+from PySide6.QtCore import (
+ QDateTime,
+ QEvent,
+ QModelIndex,
+ QPersistentModelIndex,
+ QPointF,
+ QRectF,
+ QSize,
+ Qt,
+ QTimer,
+)
from PySide6.QtGui import (
QColor,
QFont,
QFontMetrics,
QIcon,
+ QKeySequence,
+ QMouseEvent,
QPainter,
QPen,
QTextDocument,
@@ -44,6 +56,7 @@ from PySide6.QtWidgets import (
QStyledItemDelegate,
QStyleOptionButton,
QStyleOptionViewItem,
+ QTextEdit,
QToolTip,
QVBoxLayout,
QWidget,
@@ -969,7 +982,184 @@ class _PrescriptionNumberBlueDelegate(_PrescriptionNumberDelegate):
return QSize(width, max(80, ceil(document.size().height()) + 28))
+class _PrescriptionCopyText(QTextEdit):
+ """One transient, read-only text surface for the active painted cell."""
+
+ def __init__(self, table: _PrescriptionBlueTable) -> None:
+ super().__init__(table.viewport())
+ self.table = table
+ self.setObjectName("PrescriptionCopyText")
+ self.setReadOnly(True)
+ self.setFrameShape(QFrame.Shape.NoFrame)
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.setStyleSheet(
+ f"QTextEdit#PrescriptionCopyText {{background:{PALETTE['accent_soft']};"
+ f"color:{PALETTE['text']};border:0;padding:0;"
+ f"selection-background-color:{PALETTE['accent']};selection-color:white;}}"
+ )
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ self.customContextMenuRequested.connect(
+ lambda point: table.customContextMenuRequested.emit(self.viewport().mapTo(table.viewport(), point))
+ )
+ self.hide()
+
+ def keyPressEvent(self, event: Any) -> None: # noqa: N802
+ if event.matches(QKeySequence.StandardKey.Copy):
+ if self.textCursor().hasSelection():
+ self.copy()
+ else:
+ self.table.copy_cell()
+ event.accept()
+ elif event.key() == Qt.Key.Key_Escape:
+ self.table._hide_copy_text()
+ self.table.setFocus()
+ event.accept()
+ else:
+ super().keyPressEvent(event)
+
+ def wheelEvent(self, event: Any) -> None: # noqa: N802
+ self.table._hide_copy_text()
+ QApplication.sendEvent(self.table.viewport(), event)
+
+
class _PrescriptionBlueTable(SortableTable):
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ super().__init__(*args, **kwargs)
+ self._copy_index = QPersistentModelIndex()
+ self._copy_drag = False
+ self.copy_text = _PrescriptionCopyText(self)
+ for signal in (
+ self.model().modelAboutToBeReset, self.model().layoutAboutToBeChanged,
+ self.model().rowsAboutToBeRemoved, self.model().rowsAboutToBeInserted,
+ self.horizontalScrollBar().valueChanged, self.verticalScrollBar().valueChanged,
+ self.horizontalHeader().sectionResized, self.horizontalHeader().sectionMoved,
+ ):
+ signal.connect(self._hide_copy_text)
+ self.model().dataChanged.connect(self._copy_data_changed)
+ self.selectionModel().currentChanged.connect(self._copy_current_changed)
+
+ @staticmethod
+ def _copyable(index: QModelIndex) -> bool:
+ return index.isValid() and index.column() in (1, 3, 4, 5, 6, 7, 8, 9, 10)
+
+ def cell_copy_text(self, index: QModelIndex) -> str:
+ if not self._copyable(index):
+ return ""
+ if index.column() == 1:
+ return str(index.data(Qt.ItemDataRole.AccessibleTextRole) or index.data() or "")
+ return str(index.data() or "")
+
+ def row_copy_text(self, row: int) -> str:
+ header = self.horizontalHeader()
+ return "\t".join(
+ self.cell_copy_text(self.model().index(row, column)).replace("\n", " ")
+ for column in (header.logicalIndex(visual) for visual in range(self.columnCount()))
+ if column not in (0, 2) and not self.isColumnHidden(column)
+ )
+
+ def copy_cell(self) -> None:
+ text = self.cell_copy_text(self.currentIndex())
+ if text:
+ QApplication.clipboard().setText(text)
+
+ def _hide_copy_text(self, *_args: Any) -> None:
+ self.copy_text.hide()
+ self._copy_index = QPersistentModelIndex()
+ self._copy_drag = False
+
+ def _copy_current_changed(self, current: QModelIndex, _previous: QModelIndex) -> None:
+ if current != self._copy_index:
+ self._hide_copy_text()
+
+ def _copy_data_changed(self, top: QModelIndex, bottom: QModelIndex, *_args: Any) -> None:
+ if (self._copy_index.isValid()
+ and top.row() <= self._copy_index.row() <= bottom.row()
+ and top.column() <= self._copy_index.column() <= bottom.column()):
+ self._hide_copy_text()
+
+ def _show_copy_text(self, index: QModelIndex) -> None:
+ self._copy_index = QPersistentModelIndex(index)
+ self.copy_text.setGeometry(self.visualRect(index).adjusted(1, 1, -1, -1))
+ text = self.cell_copy_text(index)
+ if index.column() == 5:
+ parts = text.rsplit(" · ", 2)
+ text = parts[0] + ("\n" + " · ".join(parts[1:]) if len(parts) > 1 else "")
+ elif index.column() == 10:
+ text = "\n".join(text.rsplit(" ", 1))
+ font = QFont(body_family())
+ font.setPixelSize(14)
+ self.copy_text.setFont(font)
+ self.copy_text.setPlainText(text)
+ document = self.copy_text.document()
+ document.setDocumentMargin(0)
+ document.setTextWidth(max(1, self.copy_text.width() - 24))
+ top = max(11, round((self.copy_text.height() - document.size().height()) / 2))
+ self.copy_text.setViewportMargins(11, top, 11, 0)
+ self.copy_text.show()
+ self.copy_text.raise_()
+ self.copy_text.setFocus(Qt.FocusReason.MouseFocusReason)
+
+ def _forward_copy_mouse(self, event: QMouseEvent) -> None:
+ target = self.copy_text.viewport()
+ local = QPointF(target.mapFromGlobal(event.globalPosition().toPoint()))
+ forwarded = QMouseEvent(
+ event.type(), local, event.globalPosition(), event.button(), event.buttons(), event.modifiers()
+ )
+ QApplication.sendEvent(target, forwarded)
+ event.accept()
+
+ def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
+ index = self.indexAt(event.position().toPoint())
+ self._hide_copy_text()
+ super().mousePressEvent(event)
+ if event.button() == Qt.MouseButton.LeftButton and self._copyable(index):
+ self._show_copy_text(index)
+ self._copy_drag = True
+ self._forward_copy_mouse(event)
+
+ def mouseMoveEvent(self, event: QMouseEvent) -> None: # noqa: N802
+ if self._copy_drag:
+ self._forward_copy_mouse(event)
+ return
+ self.viewport().setCursor(
+ Qt.CursorShape.IBeamCursor if self._copyable(self.indexAt(event.position().toPoint()))
+ else Qt.CursorShape.ArrowCursor
+ )
+ super().mouseMoveEvent(event)
+
+ def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: # noqa: N802
+ index = self.indexAt(event.position().toPoint())
+ if event.button() == Qt.MouseButton.LeftButton and self._copyable(index):
+ self.setCurrentIndex(index)
+ if self._copy_index != index:
+ self._show_copy_text(index)
+ self._forward_copy_mouse(event)
+ return
+ super().mouseDoubleClickEvent(event)
+
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802
+ if self._copy_drag:
+ self._forward_copy_mouse(event)
+ self._copy_drag = False
+ return
+ super().mouseReleaseEvent(event)
+
+ def keyPressEvent(self, event: Any) -> None: # noqa: N802
+ if event.matches(QKeySequence.StandardKey.Copy):
+ if self._copyable(self.currentIndex()):
+ self.copy_cell()
+ elif self.currentRow() >= 0:
+ QApplication.clipboard().setText(self.row_copy_text(self.currentRow()))
+ event.accept()
+ return
+ super().keyPressEvent(event)
+
+ def resizeEvent(self, event: Any) -> None: # noqa: N802
+ if hasattr(self, "copy_text"):
+ self._hide_copy_text()
+ super().resizeEvent(event)
+
def setCellWidget(self, row: int, column: int, widget: QWidget) -> None: # noqa: N802
super().setCellWidget(row, column, widget)
if column == 2:
@@ -1827,18 +2017,26 @@ class PrescriptionsPage(QWidget):
present_issued_prescription_ai(self.repository, self.permissions, self, prescription_id=value)
def _open_ai_context_menu(self, position: Any) -> None:
- if not can_open_issued_ai(self.permissions) or not callable(getattr(self.repository, "list_prescription_ai_reports", None)):
- return
item = self.table.itemAt(position)
if item is None:
return
- self.table.selectRow(item.row())
+ self.table.setCurrentItem(item)
row = self._selected()
- if _is_blank_prescription(row):
- return
menu = QMenu(self.table)
- action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告")
- action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
+ selected = self.table.copy_text.textCursor().selectedText() if self.table.copy_text.isVisible() else ""
+ if selected:
+ menu.addAction("复制选中文字", lambda: QApplication.clipboard().setText(selected.replace("\u2029", "\n")))
+ text = self.table.cell_copy_text(self.table.indexFromItem(item))
+ if text:
+ menu.addAction("复制单元格", lambda: QApplication.clipboard().setText(text))
+ row_text = self.table.row_copy_text(item.row())
+ menu.addAction("复制整行", lambda: QApplication.clipboard().setText(row_text))
+ if (can_open_issued_ai(self.permissions)
+ and callable(getattr(self.repository, "list_prescription_ai_reports", None))
+ and not _is_blank_prescription(row)):
+ menu.addSeparator()
+ action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告")
+ action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
self._ai_context_menu = menu
menu.popup(self.table.viewport().mapToGlobal(position))
diff --git a/app/tests/test_consultation_assistant_names.py b/app/tests/test_consultation_assistant_names.py
new file mode 100644
index 000000000..95292e2fd
--- /dev/null
+++ b/app/tests/test_consultation_assistant_names.py
@@ -0,0 +1,139 @@
+"""Medical-assistant names must survive independent list/options loading."""
+
+from __future__ import annotations
+
+import os
+from copy import deepcopy
+from typing import Any
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+import pytest
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import QApplication, QWidget
+
+from doctor_workstation.core.errors import ApiBusinessError
+from doctor_workstation.services.repository import RemoteDoctorRepository
+from doctor_workstation.ui.pages import consultations as module
+
+
+class Client:
+ def __init__(self) -> None:
+ self.assistants: Any = [{"id": 66, "name": "周医助"}]
+ self.rows = [
+ {"id": 1, "assistant_id": "66", "patient_name": "测试患者"},
+ {"id": 2, "assistant_id": 66, "assistant_name": "诊单医助"},
+ {"id": 3, "assistant_id": 99},
+ {"id": 4, "assistant_id": 0},
+ {"id": 5, "assistant_id": None},
+ ]
+
+ def get(self, endpoint: str, params: Any = None) -> Any:
+ if endpoint == "tcm.diagnosis/lists":
+ return {"lists": deepcopy(self.rows), "count": len(self.rows)}
+ if endpoint == "tcm.diagnosis/getAssistants":
+ if isinstance(self.assistants, Exception):
+ raise self.assistants
+ return deepcopy(self.assistants)
+ return []
+
+
+class DetailStub(QWidget):
+ saved = Signal()
+
+ def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+
+
+@pytest.fixture(scope="module")
+def application() -> QApplication:
+ return QApplication.instance() or QApplication([])
+
+
+@pytest.fixture
+def page(application: QApplication, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr(module, "DiagnosisDialog", DetailStub)
+ jobs = []
+
+ def defer(function: Any, **callbacks: Any) -> None:
+ jobs.append(lambda: callbacks["on_success"](function()))
+
+ monkeypatch.setattr(module, "run_async", defer)
+ client = Client()
+ result = module.ConsultationsPage(RemoteDoctorRepository(client))
+ yield result, client, jobs
+ result.poll_timer.stop()
+ result.close()
+ result.deleteLater()
+ application.processEvents()
+
+
+@pytest.mark.parametrize("options_first", [False, True])
+def test_names_render_with_either_response_order_without_changing_source(page, options_first):
+ view, client, jobs = page
+ result = view.repository.list_consultations()
+ before = deepcopy(result)
+ view._load_filter_options()
+ if options_first:
+ jobs.pop()()
+ view._apply_result(result, view._generation)
+ model = view.table_host.model
+ model.setData(model.index(0, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
+ view.table.selectRow(0)
+ if not options_first:
+ assert model.index(0, 7).data() == "—"
+ changes = []
+ model.dataChanged.connect(lambda first, last, roles: changes.append((first, last, roles)))
+ jobs.pop()()
+ assert any(first.column() == last.column() == 7 for first, last, _ in changes)
+ assert [model.index(row, 7).data() for row in range(5)] == [
+ "周医助", "诊单医助", "—", "—", "—",
+ ]
+ assert model.index(0, 7).data(Qt.ItemDataRole.ToolTipRole) == "周医助"
+ assert [row.id for row in model.checked_records()] == [1]
+ assert view.table.current_data().id == 1
+ assert result == before
+ assert "assistant_name" not in client.rows[0]
+ # A later page uses the same lookup; reassignment uses the new ID, not a cached row name.
+ client.rows = [{"id": 1, "assistant_id": 99}, {"id": 6, "assistant_id": 66}]
+ view._apply_result(view.repository.list_consultations(), view._generation)
+ assert [model.index(row, 7).data() for row in range(2)] == ["—", "周医助"]
+
+
+@pytest.mark.parametrize("options", [[], ApiBusinessError("权限不足,无法访问或操作")])
+def test_server_name_still_displays_when_picker_empty_or_denied(page, options):
+ view, client, jobs = page
+ client.assistants = options
+ view._apply_result(view.repository.list_consultations(), view._generation)
+ view._load_filter_options()
+ jobs.pop()()
+ assert view.table_host.model.index(1, 7).data() == "诊单医助"
+ assert view.table_host.model.index(0, 7).data() == "—"
+
+
+def test_updated_and_stale_options_do_not_leave_old_assistant_names(page):
+ view, _client, jobs = page
+ view._apply_result(view.repository.list_consultations(), view._generation)
+ view._load_filter_options()
+ jobs.pop()()
+ generation = view._options_generation
+ view._apply_filter_options({"assistants": [{"id": 66, "name": "新医助"}]}, generation)
+ assert view.table_host.model.index(0, 7).data() == "新医助"
+ view._apply_filter_options({"assistants": []}, generation - 1)
+ assert view.table_host.model.index(0, 7).data() == "新医助"
+ view._apply_filter_options({"assistants": []}, generation)
+ assert view.table_host.model.index(0, 7).data() == "—"
+ assert view.table_host.model.index(1, 7).data() == "诊单医助"
+
+
+def test_long_assistant_name_resizes_both_table_sections(page):
+ view, _client, _jobs = page
+ host = view.table_host
+ view._apply_result(view.repository.list_consultations(), view._generation)
+ original_height = host.main.rowHeight(0)
+ name = "完整医助姓名用于显示换行" * 5
+ host.set_assistant_names({66: name, 0: "不能用于未分配患者", -1: "无效医助"})
+ assert host.model.index(0, 7).data() == name
+ assert host.main.rowHeight(0) > original_height
+ assert host.main.rowHeight(0) == host.fixed.rowHeight(0)
+ assert host.model.index(3, 7).data() == "—"
diff --git a/app/tests/test_consultation_departments.py b/app/tests/test_consultation_departments.py
index a2ff59c4f..4d61d447f 100755
--- a/app/tests/test_consultation_departments.py
+++ b/app/tests/test_consultation_departments.py
@@ -11,7 +11,7 @@ import pytest
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QApplication, QWidget
-from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError
+from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError, ApiTimeoutError
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.pages import consultations as module
@@ -160,8 +160,8 @@ def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch
widget._load_departments()
assert not widget._departments_loaded
assert not widget._departments_loading
- assert widget.department_combo.currentText() == "部门加载失败"
- assert "无权限" in widget.department_combo.toolTip()
+ assert widget.department_combo.currentText() == "暂无部门筛选权限"
+ assert "联系管理员" in widget.department_combo.toolTip()
assert not widget.department_retry_button.isHidden()
client.departments = TREE
widget.department_retry_button.click()
@@ -170,6 +170,19 @@ def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
+def test_network_failure_remains_retryable_and_is_not_reported_as_permission_denied(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
+ widget, client = page
+ monkeypatch.setattr(module, "run_async", immediate)
+ client.departments = ApiTimeoutError("request timed out")
+ widget._load_departments()
+ assert widget.department_combo.currentText() == "部门加载失败"
+ assert "超时" in widget.department_combo.toolTip()
+ assert "联系管理员" not in widget.department_combo.toolTip()
+ client.departments = TREE
+ widget.department_retry_button.click()
+ assert widget._departments_loaded
+
+
def test_inflight_dedup_and_stale_success_or_failure_cannot_override_selection(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, _client = page
jobs: list[dict[str, Any]] = []
diff --git a/app/tests/test_cos_upload.py b/app/tests/test_cos_upload.py
new file mode 100644
index 000000000..d35a69a76
--- /dev/null
+++ b/app/tests/test_cos_upload.py
@@ -0,0 +1,352 @@
+"""COS upload contract tests: real SDK configuration, no external requests."""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+import time
+import traceback
+from collections.abc import Iterator
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import httpx
+import pytest
+from qcloud_cos import CosClientError, CosConfig, CosS3Client
+
+from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError, ApiTransportError
+from doctor_workstation.services.api_client import ApiClient
+from doctor_workstation.services.cos_upload import upload_video_to_cos
+
+BUCKET = "synthetic-video-1234567890"
+REGION = "ap-guangzhou"
+PREFIX = "uploads/video/20261105/"
+_AUTOMATIC = object()
+
+
+def _credentials() -> dict[str, Any]:
+ now = int(time.time())
+ return {
+ "provider": "qcloud",
+ "fallback": False,
+ "bucket": BUCKET,
+ "region": REGION,
+ "key_prefix": PREFIX,
+ "object_key": "",
+ "host": "https://untrusted-host.example.test",
+ "cdn_domain": "https://cdn.example.test",
+ "max_size": 2 * 1024 * 1024 * 1024,
+ "start_time": now - 10,
+ "expired_time": now + 1800,
+ "credentials": {
+ "tmpSecretId": "synthetic-temporary-id",
+ "tmpSecretKey": "synthetic-temporary-secret",
+ "sessionToken": "synthetic-temporary-token",
+ },
+ }
+
+
+@dataclass
+class _Harness:
+ source: Path
+ credentials: Any = field(default_factory=_credentials)
+ confirmation: Any = _AUTOMATIC
+ sdk_error: Exception | None = None
+ sdk_reports_progress: bool = True
+ api_failure: str = ""
+ log_sensitive_sdk_data: bool = False
+ api_requests: list[httpx.Request] = field(default_factory=list)
+ sdk_calls: list[dict[str, Any]] = field(default_factory=list)
+ configs: list[CosConfig] = field(default_factory=list)
+ progress: list[tuple[int, int]] = field(default_factory=list)
+ events: list[str] = field(default_factory=list)
+
+ def handle(self, request: httpx.Request) -> httpx.Response:
+ self.api_requests.append(request)
+ assert request.method == "POST"
+ assert request.headers["token"] == "synthetic-admin-token"
+ endpoint = request.url.path.rsplit("/", 1)[-1]
+ assert endpoint in {"ossCredentials", "ossConfirm"}
+ self.events.append(endpoint)
+ if self.api_failure == endpoint:
+ return httpx.Response(200, json={"code": 0, "msg": "synthetic API rejection"})
+ if endpoint == "ossCredentials":
+ return httpx.Response(200, json={"code": 1, "data": self.credentials})
+ assert request.extensions["timeout"]["read"] == 120.0
+ body = json.loads(request.content)
+ result = self.confirmation
+ if result is _AUTOMATIC:
+ address = "https://cdn.example.test/" + body["key"]
+ result = {"id": 19, "uri": address, "url": address, "type": 20, "cid": 0}
+ return httpx.Response(200, json={"code": 1, "data": result})
+
+ def client(self) -> ApiClient:
+ return ApiClient(
+ "https://api.example.test",
+ token="synthetic-admin-token",
+ max_retries=5,
+ transport=httpx.MockTransport(self.handle),
+ )
+
+ def processing(self) -> None:
+ self.events.append("processing")
+
+ def report(self, sent: int, total: int) -> None:
+ self.progress.append((sent, total))
+
+
+@pytest.fixture
+def harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Harness]:
+ source = tmp_path / "synthetic-replay.mp4"
+ source.write_bytes(b"synthetic-video-bytes")
+ state = _Harness(source)
+
+ def sdk_upload(sdk: CosS3Client, **kwargs: Any) -> dict[str, str]:
+ state.configs.append(sdk.get_conf())
+ state.sdk_calls.append(kwargs)
+ state.events.append("sdk-start")
+ if state.log_sensitive_sdk_data:
+ logging.getLogger("qcloud_cos.cos_auth").debug("synthetic-temporary-secret")
+ logging.getLogger("qcloud_cos.cos_client").error("synthetic-temporary-token")
+ if state.sdk_error is not None:
+ raise state.sdk_error
+ if state.sdk_reports_progress:
+ size = source.stat().st_size
+ callback = kwargs["progress_callback"]
+ callback(size // 2, size)
+ callback(size, size)
+ callback(size * 2, size) # Defensive clamping must keep binding pending.
+ state.events.append("sdk-done")
+ return {"ETag": '"synthetic-etag"'}
+
+ def no_external_request(*_args: Any, **_kwargs: Any) -> None:
+ pytest.fail("SDK tests must never make an external request")
+
+ monkeypatch.setattr(CosS3Client, "upload_file", sdk_upload)
+ monkeypatch.setattr("requests.Session.request", no_external_request)
+ # Restore the logger after the test while exercising the helper's choice.
+ monkeypatch.setattr(logging.getLogger("qcloud_cos"), "propagate", True)
+ yield state
+
+
+@pytest.mark.parametrize("size", [1, 1024, 5 * 1024 * 1024])
+def test_official_sdk_config_and_confirm_order_keep_completion_pending(
+ harness: _Harness, size: int
+) -> None:
+ harness.source.write_bytes(b"v" * size)
+ harness.sdk_reports_progress = size > 1024 * 1024
+ with harness.client() as client:
+ result = upload_video_to_cos(
+ client,
+ harness.source,
+ mime_type="video/mp4",
+ on_progress=harness.report,
+ on_processing=harness.processing,
+ )
+
+ assert harness.events == ["ossCredentials", "sdk-start", "sdk-done", "processing", "ossConfirm"]
+ assert json.loads(harness.api_requests[0].content) == {
+ "type": "video", "name": harness.source.name
+ }
+ assert len(harness.sdk_calls) == 1
+ config = harness.configs[0]
+ call = harness.sdk_calls[0]
+ assert config._secret_id == "synthetic-temporary-id"
+ assert config._secret_key == "synthetic-temporary-secret"
+ assert config._token == "synthetic-temporary-token"
+ assert config._region == REGION
+ assert config._scheme == "https"
+ assert config._timeout == 120
+ assert config._verify_ssl is True
+ assert config._allow_redirects is False
+ assert config._domain is None
+ assert config._ip is None
+ assert config._port is None
+ assert config.uri(bucket=BUCKET, path=call["Key"]).startswith(
+ f"https://{BUCKET}.cos.{REGION}.myqcloud.com/"
+ )
+ assert call["Bucket"] == BUCKET
+ assert call["LocalFilePath"] == str(harness.source.resolve())
+ assert call["PartSize"] == 1
+ assert call["MAXThread"] == 3
+ assert call["ContentType"] == "video/mp4"
+ assert set(call) == {
+ "Bucket", "Key", "LocalFilePath", "PartSize", "MAXThread", "ContentType", "progress_callback"
+ }
+ assert re.fullmatch(re.escape(PREFIX) + r"[0-9]+-[0-9a-f]{32}\.mp4", call["Key"])
+ assert json.loads(harness.api_requests[1].content) == {
+ "type": "video", "key": call["Key"], "name": harness.source.name,
+ "size": size, "content_type": "video/mp4", "cid": 0,
+ }
+ assert result["id"] == 19
+ assert result["uri"].endswith("/" + call["Key"])
+ assert harness.progress[0] == (0, size)
+ assert harness.progress[-1] == (size - 1, size)
+ assert all(0 <= sent < total == size for sent, total in harness.progress)
+
+
+def test_fixed_object_key_is_used_without_expanding_its_scope(harness: _Harness) -> None:
+ harness.credentials["object_key"] = PREFIX + "session/fixed.mp4"
+ with harness.client() as client:
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4")
+ assert harness.sdk_calls[0]["Key"] == PREFIX + "session/fixed.mp4"
+
+
+@pytest.mark.parametrize(
+ ("field_name", "invalid"),
+ [
+ ("provider", "local"),
+ ("fallback", True),
+ ("fallback", "false"),
+ ("bucket", "bucket.example.test/escape-1234567890"),
+ ("bucket", "bucket@attacker-1234567890"),
+ ("bucket", ""),
+ ("region", "ap-guangzhou.attacker.test"),
+ ("region", "https://attacker.test"),
+ ("key_prefix", "uploads/voice/20261105/"),
+ ("key_prefix", "uploads/video/20261105/../"),
+ ("key_prefix", "uploads/video/20261399/"),
+ ("key_prefix", "uploads/video/20261105"),
+ ("object_key", "uploads/video/20261106/other.mp4"),
+ ("object_key", PREFIX + "../other.mp4"),
+ ("object_key", PREFIX + "%2e%2e/other.mp4"),
+ ("object_key", PREFIX + "nested\\other.mp4"),
+ ("object_key", PREFIX + "other.exe"),
+ ("object_key", []),
+ ("max_size", 0),
+ ("max_size", True),
+ ("start_time", 9999999999),
+ ("expired_time", 1),
+ ("expired_time", False),
+ ("credentials", None),
+ ("credentials", {"tmpSecretId": "id", "tmpSecretKey": "secret"}),
+ ("credentials", {"tmpSecretId": "id", "tmpSecretKey": "", "sessionToken": "token"}),
+ ("credentials", {"tmpSecretId": "id", "tmpSecretKey": "secret", "sessionToken": "bad\r\n"}),
+ ],
+)
+def test_invalid_credentials_fail_before_any_cos_upload(
+ harness: _Harness, field_name: str, invalid: Any
+) -> None:
+ harness.credentials[field_name] = invalid
+ with harness.client() as client, pytest.raises(ApiProtocolError):
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4")
+ assert harness.sdk_calls == []
+ assert harness.events == ["ossCredentials"]
+
+
+@pytest.mark.parametrize("response", [None, [], "invalid"])
+def test_non_mapping_credentials_are_rejected(harness: _Harness, response: Any) -> None:
+ harness.credentials = response
+ with harness.client() as client, pytest.raises(ApiProtocolError):
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4")
+ assert harness.sdk_calls == []
+
+
+def test_server_size_limit_is_enforced_before_cos_upload(harness: _Harness) -> None:
+ harness.credentials["max_size"] = harness.source.stat().st_size - 1
+ with harness.client() as client, pytest.raises(ValueError, match="上限"):
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4")
+ assert harness.sdk_calls == []
+ assert harness.events == ["ossCredentials"]
+
+
+@pytest.mark.parametrize("condition", ["missing", "empty", "oversized", "wrong-extension", "bad-mime"])
+def test_invalid_local_video_fails_before_request(harness: _Harness, condition: str) -> None:
+ mime = "video/mp4"
+ if condition == "missing":
+ harness.source.unlink()
+ elif condition == "empty":
+ harness.source.write_bytes(b"")
+ elif condition == "oversized":
+ with harness.source.open("wb") as stream:
+ stream.truncate(2 * 1024 * 1024 * 1024 + 1)
+ elif condition == "wrong-extension":
+ renamed = harness.source.with_suffix(".exe")
+ harness.source.rename(renamed)
+ harness.source = renamed
+ else:
+ mime = "video/mp4\r\nx-injected: value"
+ with harness.client() as client, pytest.raises(ValueError):
+ upload_video_to_cos(client, harness.source, mime_type=mime)
+ assert harness.api_requests == []
+ assert harness.sdk_calls == []
+
+
+def test_cos_error_is_sanitized_and_never_confirmed(
+ harness: _Harness, caplog: pytest.LogCaptureFixture
+) -> None:
+ harness.sdk_error = CosClientError("synthetic-temporary-secret synthetic-temporary-token")
+ harness.log_sensitive_sdk_data = True
+ caplog.set_level(logging.DEBUG)
+ with harness.client() as client, pytest.raises(ApiTransportError) as caught:
+ upload_video_to_cos(
+ client, harness.source, mime_type="video/mp4",
+ on_progress=harness.report, on_processing=harness.processing,
+ )
+ rendered = "".join(traceback.format_exception(caught.value))
+ assert "synthetic-temporary-secret" not in rendered + caplog.text
+ assert "synthetic-temporary-token" not in rendered + caplog.text
+ assert caught.value.data == {"provider": "cos", "operation": "upload"}
+ assert caught.value.__suppress_context__ is True
+ assert harness.events == ["ossCredentials", "sdk-start"]
+ assert harness.progress == [(0, harness.source.stat().st_size)]
+
+
+@pytest.mark.parametrize("endpoint", ["ossCredentials", "ossConfirm"])
+def test_api_business_errors_keep_their_type_and_are_not_retried(
+ harness: _Harness, endpoint: str
+) -> None:
+ harness.api_failure = endpoint
+ with harness.client() as client, pytest.raises(ApiBusinessError, match="synthetic API rejection"):
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4", on_progress=harness.report)
+ assert sum(request.url.path.endswith(endpoint) for request in harness.api_requests) == 1
+ assert len(harness.sdk_calls) == (0 if endpoint == "ossCredentials" else 1)
+ assert all(sent < total for sent, total in harness.progress)
+
+
+@pytest.mark.parametrize(
+ "invalid",
+ [
+ None,
+ [],
+ {},
+ {"id": 0},
+ {"id": True},
+ {"id": 19},
+ {"id": 19, "uri": "/uploads/video/local.mp4"},
+ {"id": 19, "uri": "file:///tmp/local.mp4"},
+ {"id": 19, "uri": "https://cdn.example.test/different.mp4"},
+ {"id": 19, "uri": "https://cdn.example.test:bad/" + PREFIX + "fixed.mp4"},
+ {"id": 19, "uri": "https://user:pass@cdn.example.test/" + PREFIX + "fixed.mp4"},
+ ],
+)
+def test_invalid_confirmation_never_reports_completion(harness: _Harness, invalid: Any) -> None:
+ harness.credentials["object_key"] = PREFIX + "fixed.mp4"
+ harness.confirmation = invalid
+ with harness.client() as client, pytest.raises(ApiProtocolError):
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4", on_progress=harness.report)
+ assert len(harness.sdk_calls) == 1
+ assert harness.events[-1] == "ossConfirm"
+ assert all(sent < total for sent, total in harness.progress)
+
+
+def test_disposed_ui_callbacks_do_not_interrupt_upload_or_confirmation(harness: _Harness) -> None:
+ processing = []
+
+ def broken_progress(_sent: int, _total: int) -> None:
+ raise RuntimeError("synthetic disposed progress receiver")
+
+ def broken_processing() -> None:
+ processing.append(True)
+ raise RuntimeError("synthetic disposed processing receiver")
+
+ with harness.client() as client:
+ result = upload_video_to_cos(
+ client, harness.source, mime_type="video/mp4",
+ on_progress=broken_progress, on_processing=broken_processing,
+ )
+ assert result["id"] == 19
+ assert processing == [True]
+ assert harness.events == ["ossCredentials", "sdk-start", "sdk-done", "ossConfirm"]
diff --git a/app/tests/test_diagnosis_order_video_visual.py b/app/tests/test_diagnosis_order_video_visual.py
index ef36eccef..9fe68e466 100755
--- a/app/tests/test_diagnosis_order_video_visual.py
+++ b/app/tests/test_diagnosis_order_video_visual.py
@@ -10,6 +10,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QDialog, QFrame, QLabel, QPushButton, QVBoxLayout
+from doctor_workstation.core.errors import ApiTimeoutError
from doctor_workstation.ui import diagnosis_media
from doctor_workstation.ui.diagnosis_drawer import RecordTable
from doctor_workstation.ui.diagnosis_media import (
@@ -53,6 +54,73 @@ class _Repository:
}
+@pytest.mark.parametrize("times_out", [False, True])
+def test_replay_upload_shows_saving_stage_and_never_invites_immediate_timeout_retry(
+ application: QApplication, monkeypatch: pytest.MonkeyPatch, times_out: bool,
+) -> None:
+ jobs: list[Any] = []
+ monkeypatch.setattr(diagnosis_module, "run_async", lambda operation, **callbacks: jobs.append((operation, callbacks)))
+ monkeypatch.setattr(
+ diagnosis_module.QFileDialog, "getOpenFileName",
+ lambda *_args, **_kwargs: ("C:/safe/replay.mp4", "视频"),
+ )
+
+ class Repository(_Repository):
+ def upload_call_recording(self, **kwargs: Any) -> dict[str, Any]:
+ assert kwargs["diagnosis_id"] == 501
+ assert kwargs["call_record_id"] == 901
+ assert kwargs["mime_type"] == "video/mp4"
+ kwargs["on_progress"](4 * 1024 * 1024, 5 * 1024 * 1024)
+ assert dialog.video_upload_bar.value() == 800
+ kwargs["on_processing"]()
+ assert dialog.video_upload_bar.maximum() == 0
+ assert "保存回放" in dialog.video_upload_button.text()
+ assert "80%" not in dialog.video_upload_status.text()
+ assert not dialog.video_upload_button.isEnabled()
+ if times_out:
+ raise ApiTimeoutError(
+ "confirmation timed out", data={"endpoint": "upload/ossConfirm"}
+ )
+ kwargs["on_progress"](5 * 1024 * 1024, 5 * 1024 * 1024)
+ return {"completed": True}
+
+ dialog = DiagnosisDialog(Repository(), permissions=["*"])
+ dialog._diagnosis_id = 501
+ dialog._editable = dialog._can_video_upload = True
+ reloads: list[str] = []
+ monkeypatch.setattr(dialog, "_ensure_tab_loaded", lambda key, **_kwargs: reloads.append(key))
+ dialog._upload_call_recording(901)
+ relay = dialog._video_upload_relay
+ dialog._upload_call_recording(901)
+ assert len(jobs) == 1
+ operation, callbacks = jobs[0]
+ try:
+ result = operation()
+ except ApiTimeoutError as error:
+ callbacks["on_error"](error)
+ else:
+ callbacks["on_success"](result)
+ callbacks["on_finished"]()
+
+ if times_out:
+ assert "视频已上传" in dialog.drawer_banner.label.text()
+ assert "避免立即重复上传" in dialog.drawer_banner.label.text()
+ assert reloads == []
+ else:
+ assert "已追加" in dialog.drawer_banner.label.text()
+ assert reloads == ["video"]
+ assert dialog.video_upload_frame.isHidden()
+ assert dialog.video_upload_button.isEnabled()
+ # Queued progress from a finished upload cannot resurrect its busy state.
+ relay.processing.emit()
+ relay.progressed.emit(1, 2)
+ assert dialog.video_upload_frame.isHidden()
+ assert dialog.video_upload_button.isEnabled()
+ dialog.close()
+ dialog.deleteLater()
+ application.processEvents()
+
+
def _rich_order() -> dict[str, Any]:
return {
"id": 801,
diff --git a/app/tests/test_prescription_security_ui.py b/app/tests/test_prescription_security_ui.py
index 024e096a5..aaf37f840 100755
--- a/app/tests/test_prescription_security_ui.py
+++ b/app/tests/test_prescription_security_ui.py
@@ -7,8 +7,8 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
-from PySide6.QtCore import Qt
-from PySide6.QtWidgets import QApplication, QDialog
+from PySide6.QtCore import QPoint, Qt
+from PySide6.QtWidgets import QApplication, QDialog, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui import widgets
@@ -412,6 +412,114 @@ def _finish_queued(callback: dict[str, Any], result: Any) -> None:
callback["on_finished"]()
+@pytest.mark.parametrize("owner_width", [1024, 1440])
+def test_embedded_prescription_history_opens_full_detail_without_changing_draft(
+ application: QApplication, monkeypatch: pytest.MonkeyPatch, owner_width: int,
+) -> None:
+ listed: list[int] = []
+ fetched: list[int] = []
+ shown: list[int] = []
+
+ class Repository(_DiagnosisRepository):
+ def get_doctor_notes(self, _diagnosis_id: int) -> list[Any]:
+ return []
+
+ def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
+ listed.append(diagnosis_id)
+ return [
+ {"id": 92, "diagnosis_id": diagnosis_id, "prescription_date": "2026-11-03",
+ "doctor_name": "陈医生", "clinical_diagnosis": "气阴两虚", "audit_status": 1},
+ {"id": 81, "diagnosis_id": diagnosis_id, "prescription_date": "2026-10-20",
+ "doctor_name": "李医生", "clinical_diagnosis": "脾虚", "void_status": 1},
+ ]
+
+ def get_prescription(self, prescription_id: int) -> dict[str, Any]:
+ fetched.append(prescription_id)
+ return {"id": prescription_id, "diagnosis_id": 745, "patient_name": "测试患者",
+ "herbs": [{"name": "黄芪", "dosage": 15}], "usage_instruction": "水煎服"}
+
+ def inspect_detail(dialog: dialog_module.PrescriptionDetailDialog) -> int:
+ shown.append(dialog.prescription["id"])
+ assert "黄芪" in dialog.document.toPlainText()
+ assert "水煎服" in dialog.document.toPlainText()
+ assert dialog.diagnosis_button.isHidden()
+ assert dialog.orders_button.isHidden()
+ return QDialog.DialogCode.Rejected
+
+ monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
+ monkeypatch.setattr(dialog_module.PrescriptionDetailDialog, "exec", inspect_detail)
+ owner = QDialog()
+ owner.resize(owner_width, 800)
+ owner.show()
+ editor = PrescriptionEditorDialog(
+ Repository(), {"diagnosis_id": 745, "patient_name": "测试患者"}, mode="edit",
+ permissions=PermissionSet(["tcm.diagnosis/readonlyDetail", "tcm.diagnosis/chufang"]),
+ parent=owner,
+ )
+ editor.show()
+ application.processEvents()
+ editor.clinical_diagnosis.setPlainText("尚未保存的新诊断")
+ draft = editor.payload()
+ editor.diagnosis_button.click()
+ for _ in range(6):
+ application.processEvents()
+ detail = editor._diagnosis_view
+ assert detail is not None
+ table = detail.readonly_prescriptions_table
+ assert listed == [745]
+ assert table.rowCount() == 2
+ assert table.item(0, 5).text() == "审核通过"
+ assert table.item(1, 5).text() == "已作废"
+ assert table.horizontalScrollBar().maximum() == 0
+ assert table.mapToGlobal(QPoint(table.width(), 0)).x() <= detail.readonly_scroll.viewport().mapToGlobal(
+ QPoint(detail.readonly_scroll.viewport().width(), 0)
+ ).x()
+ assert detail._readonly_sections["prescription"].y() < detail.case_grid.y()
+ actions = table.cellWidget(1, 6).findChildren(QPushButton)
+ assert [button.text() for button in actions] == ["查看"]
+ actions[0].click()
+ assert fetched == [81] and shown == [81]
+ assert editor.payload() == draft
+ assert editor.diagnosis_host.isVisibleTo(editor)
+ detail.readonly_close_button.click()
+ editor.diagnosis_button.click()
+ assert listed == [745, 745]
+ assert editor.payload() == draft
+ editor.reject()
+ owner.close()
+ application.processEvents()
+
+
+def test_readonly_history_respects_permission_and_shows_empty_and_failure(
+ application: QApplication, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ calls: list[int] = []
+
+ class Repository(_DiagnosisRepository):
+ def get_doctor_notes(self, _diagnosis_id: int) -> list[Any]:
+ return []
+
+ def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[Any]:
+ calls.append(diagnosis_id)
+ if len(calls) > 1:
+ raise RuntimeError("测试加载失败")
+ return []
+
+ monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
+ dialog = DiagnosisDialog(Repository(), permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]))
+ dialog.open_for(745, editable=False)
+ assert dialog._readonly_sections["prescription"].isHidden()
+ assert calls == []
+ dialog.refresh_permissions(PermissionSet(["tcm.diagnosis/readonlyDetail", "tcm.diagnosis/chufang"]))
+ dialog.open_for(745, editable=False)
+ assert "暂无历史处方" in dialog.readonly_prescriptions_table.empty_label.text()
+ dialog.open_for(745, editable=False)
+ assert "加载失败" in dialog.readonly_prescriptions_table.empty_label.text()
+ assert dialog.readonly_prescriptions_table.rowCount() == 0
+ dialog.close()
+ application.processEvents()
+
+
def test_prescription_lists_snapshot_queries_and_supersede_stale_refresh(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
diff --git a/app/tests/test_prescriptions_tech_blue.py b/app/tests/test_prescriptions_tech_blue.py
index 3c529ec04..a931b5c04 100755
--- a/app/tests/test_prescriptions_tech_blue.py
+++ b/app/tests/test_prescriptions_tech_blue.py
@@ -246,6 +246,94 @@ def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(applicatio
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
+@pytest.mark.parametrize("column", [1, 3, 4, 5, 6, 7, 8, 9, 10])
+def test_all_prescription_data_cells_select_and_copy_without_editing(
+ shell_factory, application, column,
+) -> None:
+ _shell, page = shell_factory()
+ table = page.table
+ item = table.item(0, column)
+ original = item.text()
+ row_id = _record_id(table, 0)
+ QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=table.visualItemRect(item).center())
+ assert table.copy_text.isVisible()
+ assert _record_id(table, table.currentRow()) == row_id
+ assert table.cellWidget(0, column) is None
+ QTest.keyClick(table.copy_text, Qt.Key.Key_A, Qt.KeyboardModifier.ControlModifier)
+ QTest.keyClick(table.copy_text, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier)
+ copied = application.clipboard().text()
+ if column == 1:
+ assert original in copied and f"ID: {row_id}" in copied
+ elif column == 5:
+ assert copied == original.replace(" · ", "\n", 1).replace(" · ", " · ")
+ elif column == 10:
+ assert copied == "\n".join(original.rsplit(" ", 1))
+ else:
+ assert copied == original
+ QTest.keyClicks(table.copy_text, "must-not-edit")
+ assert item.text() == original
+ assert table.item(0, 0).checkState() == Qt.CheckState.Unchecked
+
+
+def test_text_drag_copy_and_double_click_do_not_open_prescription(shell_factory, application, monkeypatch):
+ _shell, page = shell_factory()
+ table = page.table
+ opened = []
+ monkeypatch.setattr(page, "_view_selected", lambda: opened.append(True))
+ item = table.item(1, 8)
+ rect = table.visualItemRect(item)
+ start = QPoint(rect.left() + 12, rect.center().y())
+ end = QPoint(rect.right() - 12, start.y())
+ QTest.mousePress(table.viewport(), Qt.MouseButton.LeftButton, pos=start)
+ QTest.mouseMove(table.viewport(), end, 30)
+ QTest.mouseRelease(table.viewport(), Qt.MouseButton.LeftButton, pos=end)
+ assert table.copy_text.textCursor().selectedText() == item.text()
+ QTest.keyClick(table.copy_text, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier)
+ assert application.clipboard().text() == item.text()
+ table.copy_text.customContextMenuRequested.emit(QPoint(4, 4))
+ menu = page._ai_context_menu
+ selected_action = next(action for action in menu.actions() if action.text() == "复制选中文字")
+ application.clipboard().clear()
+ selected_action.trigger()
+ assert application.clipboard().text() == item.text()
+ menu.close()
+ QTest.mouseDClick(table.viewport(), Qt.MouseButton.LeftButton, pos=start + QPoint(8, 0))
+ assert table.copy_text.textCursor().hasSelection()
+ assert opened == []
+
+
+def test_copy_context_menu_and_refresh_follow_the_visible_row(shell_factory, application):
+ _shell, page = shell_factory()
+ table = page.table
+ # Copy remains available without AI/report permissions, including blank prescriptions.
+ page.permissions = PermissionSet([])
+ table.sortItems(1, Qt.SortOrder.DescendingOrder)
+ item = table.item(0, 1)
+ page._open_ai_context_menu(table.visualItemRect(item).center())
+ menu = page._ai_context_menu
+ actions = {action.text(): action for action in menu.actions()}
+ assert set(actions) == {"复制单元格", "复制整行"}
+ actions["复制单元格"].trigger()
+ assert item.text() in application.clipboard().text()
+ assert f"ID: {_record_id(table, 0)}" in application.clipboard().text()
+ actions["复制整行"].trigger()
+ copied = application.clipboard().text()
+ for column in range(3, 11):
+ assert table.item(0, column).text() in copied
+ assert "\t" in copied and "复制" not in copied
+ menu.close()
+ QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=table.visualItemRect(item).center())
+ assert table.copy_text.isVisible()
+ table.sortItems(1, Qt.SortOrder.AscendingOrder)
+ assert table.copy_text.isHidden()
+ table.setCurrentItem(table.item(0, 5))
+ QTest.keyClick(table, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier)
+ assert application.clipboard().text() == table.item(0, 5).text()
+ QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=table.visualItemRect(table.item(0, 5)).center())
+ page.refresh()
+ assert table.copy_text.isHidden()
+
+
def test_long_doctor_button_paints_compactly_without_truncating_real_value(shell_factory, application) -> None:
_shell, page = shell_factory(1024, 768)
page.filter_disclosure.set_expanded(True)
diff --git a/app/tests/test_recording_cos_repository.py b/app/tests/test_recording_cos_repository.py
new file mode 100644
index 000000000..18762296a
--- /dev/null
+++ b/app/tests/test_recording_cos_repository.py
@@ -0,0 +1,144 @@
+"""Exercise the real COS SDK and repository with synthetic HTTP responses."""
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+from urllib.parse import parse_qs, urlsplit
+
+import httpx
+import pytest
+import requests
+
+from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError
+from doctor_workstation.services.api_client import ApiClient
+from doctor_workstation.services.repository import RemoteDoctorRepository
+
+
+@pytest.mark.parametrize("size", [1024, 5 * 1024 * 1024])
+@pytest.mark.parametrize("record_id", [None, 901])
+def test_real_sdk_sends_video_only_to_cos_before_confirming_and_binding(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, size: int, record_id: int | None,
+) -> None:
+ source = tmp_path / "synthetic-replay.mp4"
+ source.write_bytes(b"v" * size)
+ api_calls: list[str] = []
+ cos_requests: list[requests.PreparedRequest] = []
+ uploaded_parts: dict[int, bytes] = {}
+ progress: list[int] = []
+ stages: list[str] = []
+ confirmed_urls: list[str] = []
+
+ def cos_send(_session, request, **kwargs):
+ parsed = urlsplit(request.url)
+ assert parsed.scheme == "https"
+ assert parsed.hostname == "test-bucket-1250000000.cos.ap-guangzhou.myqcloud.com"
+ assert request.headers["x-cos-security-token"] == b"synthetic-sts-token"
+ assert "q-sign-algorithm=sha1" in request.headers["Authorization"]
+ assert "token" not in request.headers
+ cos_requests.append(request)
+ query = parse_qs(parsed.query, keep_blank_values=True)
+ if request.method == "GET":
+ body = b"false"
+ elif request.method == "POST" and "uploads" in query:
+ body = b"synthetic-upload"
+ elif request.method == "PUT":
+ part = int(query.get("partNumber", ["1"])[0])
+ uploaded_parts[part] = request.body.read() if hasattr(request.body, "read") else request.body
+ body = b""
+ else:
+ assert request.method == "POST" and query.get("uploadId") == ["synthetic-upload"]
+ body = b'"synthetic-etag"'
+ response = requests.Response()
+ response.status_code = 200
+ response.url = request.url
+ response.request = request
+ response._content = body
+ response.headers.update({"ETag": '"synthetic-etag"', "Content-Type": "application/xml"})
+ return response
+
+ monkeypatch.setattr(requests.Session, "send", cos_send)
+
+ def api_handler(request: httpx.Request) -> httpx.Response:
+ endpoint = request.url.path.removeprefix("/adminapi/")
+ api_calls.append(endpoint)
+ assert request.method == "POST"
+ assert request.headers["token"] == "synthetic-admin-token"
+ payload = json.loads(request.content)
+ if endpoint == "upload/ossCredentials":
+ assert payload == {"type": "video", "name": source.name}
+ data = {
+ "provider": "qcloud", "fallback": False,
+ "bucket": "test-bucket-1250000000", "region": "ap-guangzhou",
+ "host": "https://test-bucket-1250000000.cos.ap-guangzhou.myqcloud.com",
+ "key_prefix": "uploads/video/20261105/", "max_size": 2 * 1024**3,
+ "expired_time": int(time.time()) + 1800, "start_time": int(time.time()) - 10,
+ "credentials": {"tmpSecretId": "synthetic-id", "tmpSecretKey": "synthetic-key", "sessionToken": "synthetic-sts-token"},
+ }
+ elif endpoint == "upload/ossConfirm":
+ assert b"".join(uploaded_parts[i] for i in sorted(uploaded_parts)) == source.read_bytes()
+ assert stages == ["processing"]
+ assert size not in progress
+ assert payload == {
+ "type": "video", "key": payload["key"], "name": source.name,
+ "size": size, "content_type": "video/mp4", "cid": 0,
+ }
+ assert payload["key"].startswith("uploads/video/20261105/")
+ assert payload["key"].endswith(".mp4")
+ confirmed_urls.append("https://cdn.example.test/" + payload["key"])
+ data = {"id": 101, "uri": confirmed_urls[-1]}
+ elif endpoint == "tcm.diagnosis/createManualCallRecord":
+ assert payload == {"diagnosis_id": 501}
+ assert size not in progress
+ data = {"id": 901}
+ else:
+ assert endpoint == "tcm.diagnosis/attachLocalCallRecording"
+ assert payload == {"diagnosis_id": 501, "call_record_id": 901, "file_url": confirmed_urls[-1]}
+ assert size not in progress
+ data = []
+ return httpx.Response(200, json={"code": 1, "data": data})
+
+ with ApiClient("https://api.example.test", token="synthetic-admin-token", transport=httpx.MockTransport(api_handler)) as client:
+ result = RemoteDoctorRepository(client).upload_call_recording(
+ source, 501, call_record_id=record_id, mime_type="video/mp4",
+ on_progress=lambda sent, total: progress.append(sent),
+ on_processing=lambda: stages.append("processing"),
+ )
+ assert result["completed"] is True and result["call_record_id"] == 901
+ assert result["media_kind"] == "video"
+ assert progress[0] == 0 and progress[-1] == size
+ assert cos_requests
+ assert api_calls == [
+ "upload/ossCredentials", "upload/ossConfirm",
+ *(["tcm.diagnosis/createManualCallRecord"] if record_id is None else []),
+ "tcm.diagnosis/attachLocalCallRecording",
+ ]
+
+
+@pytest.mark.parametrize("failure", ["missing-record", "attach-failed"])
+def test_video_is_not_complete_until_exact_record_is_bound(tmp_path: Path, monkeypatch, failure):
+ from doctor_workstation.services import repository as repository_module
+
+ source = tmp_path / "synthetic.mp4"
+ source.write_bytes(b"video")
+ monkeypatch.setattr(repository_module, "upload_video_to_cos", lambda *args, **kwargs: {
+ "id": 101, "uri": "https://cdn.example.test/replay.mp4",
+ })
+ calls = []
+ progress = []
+
+ def handler(request):
+ calls.append(request.url.path)
+ if request.url.path.endswith("createManualCallRecord"):
+ return httpx.Response(200, json={"code": 1, "data": {"id": 0 if failure == "missing-record" else 901}})
+ return httpx.Response(200, json={"code": 0, "msg": "绑定失败"})
+
+ with (
+ ApiClient("https://api.example.test", transport=httpx.MockTransport(handler)) as client,
+ pytest.raises(ApiProtocolError if failure == "missing-record" else ApiBusinessError),
+ ):
+ RemoteDoctorRepository(client).upload_call_recording(
+ source, 501, on_progress=lambda sent, total: progress.append(sent),
+ )
+ assert source.stat().st_size not in progress
+ assert len(calls) == (1 if failure == "missing-record" else 2)
diff --git a/app/uv.lock b/app/uv.lock
index 342abd964..32c5a4776 100755
--- a/app/uv.lock
+++ b/app/uv.lock
@@ -167,6 +167,153 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
]
+[[package]]
+name = "charset-normalizer"
+version = "3.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" },
+ { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" },
+ { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707, upload-time = "2026-09-30T04:35:11.575Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747, upload-time = "2026-09-30T04:35:13.022Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972, upload-time = "2026-09-30T04:35:14.606Z" },
+ { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339, upload-time = "2026-09-30T04:35:16.087Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683, upload-time = "2026-09-30T04:35:17.488Z" },
+ { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143, upload-time = "2026-09-30T04:35:18.849Z" },
+ { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358, upload-time = "2026-09-30T04:35:20.326Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766, upload-time = "2026-09-30T04:35:21.72Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432, upload-time = "2026-09-30T04:35:23.273Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470, upload-time = "2026-09-30T04:35:24.655Z" },
+ { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688, upload-time = "2026-09-30T04:35:26.225Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932, upload-time = "2026-09-30T04:35:27.796Z" },
+ { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828, upload-time = "2026-09-30T04:35:29.259Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" },
+ { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" },
+ { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" },
+ { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" },
+ { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" },
+ { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" },
+ { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" },
+ { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" },
+ { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" },
+ { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" },
+ { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" },
+ { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" },
+ { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" },
+ { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" },
+ { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" },
+ { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" },
+ { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" },
+ { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" },
+ { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" },
+ { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" },
+ { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" },
+ { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" },
+ { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" },
+ { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" },
+ { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" },
+ { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" },
+ { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" },
+ { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" },
+ { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" },
+ { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" },
+ { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" },
+ { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" },
+ { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" },
+ { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" },
+ { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" },
+ { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" },
+ { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" },
+ { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" },
+ { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
+ { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
+ { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
+ { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
+ { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
+ { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
+ { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
+]
+
[[package]]
name = "click"
version = "8.5.0"
@@ -185,6 +332,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "cos-python-sdk-v5"
+version = "1.9.44"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "crcmod" },
+ { name = "pycryptodome" },
+ { name = "requests" },
+ { name = "six" },
+ { name = "xmltodict" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/c8/5cc0d0bd4b4bd9006976408bf390927fe51138658235c512a76799932de0/cos_python_sdk_v5-1.9.44.tar.gz", hash = "sha256:2ab403f3b64efdbb9a1984ad3e4381bd7f0755d1064148aeb25269bd757b49ab", size = 103461, upload-time = "2026-05-29T03:50:19.02Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/65/ed/4eabca16f3f9049ac0ee5bd17ef42709a6ede659e81e97f74e77a1aae30b/cos_python_sdk_v5-1.9.44-py3-none-any.whl", hash = "sha256:584a97ecd5e9b15b04a1e511e6dd0ea4eca00ee87fe50aa9a3c91ce716149bcb", size = 99127, upload-time = "2026-05-29T03:50:17.35Z" },
+]
+
[[package]]
name = "coverage"
version = "7.15.4"
@@ -304,6 +467,12 @@ toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
+[[package]]
+name = "crcmod"
+version = "1.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" }
+
[[package]]
name = "cryptography"
version = "50.0.0"
@@ -941,6 +1110,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
+[[package]]
+name = "pycryptodome"
+version = "4.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/52/6bf92ead9cfcfc788d9f07f2f8dad4592b190d36ed3d26a5985bbf95ff5d/pycryptodome-4.3.0.tar.gz", hash = "sha256:663fc5e1c4a3562d00c3a9b265fb94ef5f68c6085d982638b83e713af5487c06", size = 1807722, upload-time = "2026-11-02T22:16:15.811Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/93/7fb75b62af22dfa3df1392472aa5c63fdfd54665e975147c62f5c3c2157f/pycryptodome-4.3.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:3ef46fd43f2a4c0b0d80387f503d13a556956e457c6df31270a4ffda0e66729c", size = 1782069, upload-time = "2026-11-02T22:15:13.166Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/0a/b9d263c6290754ed5f2b4d72e7fe3a9fba2bd1b7d20060f4eaca4773f33d/pycryptodome-4.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5278d7ab512e712fb4ac1288aa4acf5136e72cb906f32ccb428c2fef678ff1b4", size = 1536024, upload-time = "2026-11-02T22:15:15.93Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/26/2def69ac920a0202d90fcaf07de7c0c120fb9c45157ca473d69d0617134d/pycryptodome-4.3.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:52c2fb32a447297ed600f3b019d82c68664a800d87a13a9446a1a2cf7074b2c4", size = 2508799, upload-time = "2026-11-02T22:15:17.524Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/3a/e8b07dc3a9a28e69cc35e13619bba5c36afa5c61e8868eed224fc0d23e67/pycryptodome-4.3.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:20d9b199c2864f32727413dae3644d794567eeeed9c8a2167e39896c28b2a671", size = 3010278, upload-time = "2026-11-02T22:15:19.513Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/34/94a841087b7995cc32d8239fc68d461a7077ff2a41bf27198aad6c819add/pycryptodome-4.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43d55fa266b5d5c661d23a9e8f9875687aff6693301a9c238b22b3b6e2fb3045", size = 2539885, upload-time = "2026-11-02T22:15:21.608Z" },
+ { url = "https://files.pythonhosted.org/packages/05/00/eb1578d00b9fad8aa9b52c5c468537094be38093ad0dcf6f5e6007359149/pycryptodome-4.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e9adc4f6bbc6d011ad2b525bd3043641cf98b44ebd687b434c59b446dda142bd", size = 3573277, upload-time = "2026-11-02T22:15:24.194Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/d1/36a4e4aa0e0bbd02ea650cd3cc12261324b8f60b441c7641286c51814593/pycryptodome-4.3.0-cp315-cp315t-win32.whl", hash = "sha256:185ff29a6fff096bc17ff7043ab659aefdcf8e0650a3b43fd150113a3083c52c", size = 1321211, upload-time = "2026-11-02T22:15:26.159Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/e6/f9678fed6f68b650d54472e691159a3b9ae03e827fedb403c9344ac28573/pycryptodome-4.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:ac80c8ffe9dd8018a2871c1b8a09819757af2ca08636feefbb838e71fe384aff", size = 1704790, upload-time = "2026-11-02T22:15:28.062Z" },
+ { url = "https://files.pythonhosted.org/packages/25/ec/6232f74a392dbbc1fabf40395f089d6029ea6a32f6deb57bbbb003be9e2e/pycryptodome-4.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:72e420852034a9a6abeeb4dd445d73a34e7cd248434a38f179067bc1f5368daf", size = 1393905, upload-time = "2026-11-02T22:15:29.657Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/ad/d190fc7e81cf8080ba6e13a03407ed3ec85642d25e2f4c19b27b8e300b02/pycryptodome-4.3.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a3accc029e09410ae4d9cbb08e060552e81d7fc032267afb8b4555c65301bb0f", size = 1776334, upload-time = "2026-11-02T22:15:31.579Z" },
+ { url = "https://files.pythonhosted.org/packages/98/0c/f832024cfb9ebb4b72fe567b1f46b2a1fe15fb04cc2ea6d11a3d511e42f8/pycryptodome-4.3.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a35b24c1fbc4149041c9372b1560dc4689fe6904e998960f9cd1c403d832376b", size = 1529089, upload-time = "2026-11-02T22:15:33.617Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/0d/9ccadac4042b572d17a63324cc7783f2aec46de4ac1bcc4d5b7cff151540/pycryptodome-4.3.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7e8f84d406ab75117de563a93b61ff97236cc59b5570c8c36a7ce3427f6f931", size = 2504557, upload-time = "2026-11-02T22:15:35.875Z" },
+ { url = "https://files.pythonhosted.org/packages/af/1b/b0f5f020bd43351932df9d16857330d3304715c4b1f7f783dc6e58cb7919/pycryptodome-4.3.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:96bf142ef25544a221a2b4a8f5d449cb07c1736be71b4ea057749318a2cd402c", size = 2996511, upload-time = "2026-11-02T22:15:37.922Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/55/9e19453c09eeb4a7b76de88c5e9fd472d98011099fbdef95de9d56b284e5/pycryptodome-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c7f1654f356bf688840f4fbee72fe946a2e170ee84052ded78e9897c5afbc39f", size = 2529837, upload-time = "2026-11-02T22:15:40.273Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/81/b38fb897bfc2b4911df1c6a7aa1cf331ed7d380956a5ef4149fb9cdeb577/pycryptodome-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ef3e17934d1b8ee437175fb9dcbf892067e197acc141545d76f37db2a79dd184", size = 3562951, upload-time = "2026-11-02T22:15:43.142Z" },
+ { url = "https://files.pythonhosted.org/packages/31/7c/352c683d124b575bd0ef49de5d284ecad60ebc8036e886f4c75898def454/pycryptodome-4.3.0-cp39-abi3-win32.whl", hash = "sha256:cfda47fd1745477c748b71bf726d2db99c184eb301c631992dd3a423760e000f", size = 1307755, upload-time = "2026-11-02T22:15:45.341Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/52/7d0830ccb961c9e7224b88a60df1a4eab7fa6c5fd426d7b7c972988a4cac/pycryptodome-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:195c9d781905605b3b9de8567c47986a9d0ee513566b5150eb9400692465b610", size = 1675134, upload-time = "2026-11-02T22:15:47.091Z" },
+ { url = "https://files.pythonhosted.org/packages/23/cc/41c1b55a420820ec4b2a23c877c4fa4330df0dc6cdaeb0dc88967d57b238/pycryptodome-4.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:85ece8fd90ae325d7d519f2ac61fbcc20cf4475afaba328f0c636da6077787d3", size = 1365927, upload-time = "2026-11-02T22:15:48.794Z" },
+ { url = "https://files.pythonhosted.org/packages/81/60/1d28bc91e1958be6b7b0a1b05aeb761145d72d8364e7e932d66402bca245/pycryptodome-4.3.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:5467b62cfd54c371a262874cb5784be9d2d1c2da2541e39952d0d38a8336d339", size = 1485378, upload-time = "2026-11-02T22:15:59.22Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/bc/e09fb5f0ed65d8cdb76bd31e9433c14e7eb0950abd255d67e68cb86c12e2/pycryptodome-4.3.0-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2ede0d69c7928ca2320258c70deaedf83f7350ca2a578fe01373e37d7273866", size = 1313604, upload-time = "2026-11-02T22:16:01.471Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/5e/6c87676ffc96790c30ccb60917c5e9e06450df89168db8caf650cb698d9b/pycryptodome-4.3.0-pp311-pypy311_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6670fc0ce7e6a6085d29317a0edea2e2fe14761493468d07b1e021ae3d8629a3", size = 1443883, upload-time = "2026-11-02T22:16:03.424Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/ba/7c534c1d9295911542a80c863ec8cd47ec7803281a59563f73c89ec5a5de/pycryptodome-4.3.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:29f20544f485f73f3c0f9c734327a920413b9688b22d68e9968ac9681439932f", size = 1678725, upload-time = "2026-11-02T22:16:05.373Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/b2/67206132363af8245cf233b3f959046a8b5cd58981bd55b36b685e30c1e4/pycryptodome-4.3.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:4cf6cc92277a1c82ee6e4abcb8c1f94a4ce08b00dcbb50507eb332a09623a945", size = 1485362, upload-time = "2026-11-02T22:16:07.15Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/b7/8d941f5e89b0e5aa53b673af64c0ddb750b4a502bbd65f61c4b144bd7d1c/pycryptodome-4.3.0-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:effb321424ed70ee8ac9a65eb6dd6eeb33d3426d0ae7dd3499adb6a8e75224cf", size = 1313605, upload-time = "2026-11-02T22:16:10.068Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/44/b895d9dfd61fc3387331435b0ac1b872052ab87423061611402134ecff9b/pycryptodome-4.3.0-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a2131aabc5214f0c19459b581cad14503d567c55ddf3a24ff6150b4fb436b8d", size = 1443883, upload-time = "2026-11-02T22:16:12.126Z" },
+ { url = "https://files.pythonhosted.org/packages/51/bd/31a3f0683f70f663192981ee27f4ca19c5ed8b7a9db8366bb7fe4cb04297/pycryptodome-4.3.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:70b8127d1127006772208188995bddc4c1021244bb2deca06d2226f040bfbcb6", size = 1678723, upload-time = "2026-11-02T22:16:13.908Z" },
+]
+
[[package]]
name = "pygments"
version = "2.20.0"
@@ -1141,6 +1344,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
[[package]]
name = "ruff"
version = "0.16.2"
@@ -1200,6 +1418,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" },
]
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
[[package]]
name = "tokenizers"
version = "0.23.3"
@@ -1318,10 +1545,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
+[[package]]
+name = "urllib3"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
+]
+
+[[package]]
+name = "xmltodict"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" },
+]
+
[[package]]
name = "zhenyang-doctor-workstation"
source = { editable = "." }
dependencies = [
+ { name = "cos-python-sdk-v5" },
{ name = "httpx" },
{ name = "keyring" },
{ name = "opencc-python-reimplemented" },
@@ -1345,6 +1591,7 @@ local-asr = [
[package.metadata]
requires-dist = [
+ { name = "cos-python-sdk-v5", specifier = "==1.9.44" },
{ name = "faster-whisper", marker = "extra == 'local-asr'", specifier = "==1.2.1" },
{ name = "httpx", specifier = ">=0.27.2,<1" },
{ name = "keyring", specifier = ">=25.5,<26" },
diff --git a/server/app/adminapi/http/middleware/AuthMiddleware.php b/server/app/adminapi/http/middleware/AuthMiddleware.php
index eadfd739f..ebfd576e7 100755
--- a/server/app/adminapi/http/middleware/AuthMiddleware.php
+++ b/server/app/adminapi/http/middleware/AuthMiddleware.php
@@ -80,6 +80,17 @@ class AuthMiddleware
// 当前访问路径
$accessUri = strtolower($request->controller() . '/' . $request->action());
+ // 患者列表的部门筛选复用列表权限;只允许 GET 查询显式启用数据范围的部门树。
+ // 必须与 DeptController::all 读取同一个 GET 参数,避免请求体参数意外放行全量部门。
+ if ($accessUri === 'dept.dept/all'
+ && $request->isGet()
+ && in_array($request->get('apply_data_scope'), [1, '1'], true)) {
+ $uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
+ if (in_array('tcm.diagnosis/lists', $uris, true)) {
+ return $next($request);
+ }
+ }
+
// Screenshot analysis shares the assistant grant and must never use the
// unregistered-route bypass, even before menus have been synchronized.
if ($accessUri === 'tcm.diagnosis/aitonguescreenshot') {
diff --git a/server/app/adminapi/lists/tcm/DiagnosisLists.php b/server/app/adminapi/lists/tcm/DiagnosisLists.php
index ebf6bada7..a4c332e98 100755
--- a/server/app/adminapi/lists/tcm/DiagnosisLists.php
+++ b/server/app/adminapi/lists/tcm/DiagnosisLists.php
@@ -184,6 +184,21 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
+
+ // 仅补充已通过权限过滤且落在本页的医助姓名,不依赖调用者拥有后台人员列表权限。
+ $assistantIds = array_values(array_unique(array_filter(array_map(
+ 'intval',
+ array_column($lists, 'assistant_id')
+ ), static function (int $id): bool {
+ return $id > 0;
+ })));
+ $assistantNames = $assistantIds === []
+ ? []
+ : Admin::whereIn('id', $assistantIds)->whereNull('delete_time')->column('name', 'id');
+ foreach ($lists as &$item) {
+ $item['assistant_name'] = trim((string) ($assistantNames[(int) ($item['assistant_id'] ?? 0)] ?? ''));
+ }
+ unset($item);
// 关联挂号:doctor_appointment.patient_id 存诊单主键 id(与 Appointment 列表 join 一致)
$diagnosisIds = array_filter(array_unique(array_column($lists, 'id')));
diff --git a/server/tests/DiagnosisAssistantNameTest.php b/server/tests/DiagnosisAssistantNameTest.php
new file mode 100644
index 000000000..31063c4e1
--- /dev/null
+++ b/server/tests/DiagnosisAssistantNameTest.php
@@ -0,0 +1,261 @@
+where(...$condition);
+ }
+ return $this;
+ }
+ if (func_num_args() === 2) {
+ $value = $operator;
+ $operator = '=';
+ }
+ if ($operator === 'in') {
+ return $this->whereIn($field, $value);
+ }
+ if ($operator !== '=') {
+ throw new RuntimeException('Unexpected fixture operator: ' . $operator);
+ }
+ $this->conditions[] = [$field, '=', $value];
+ return $this;
+ }
+
+ public function whereIn(string $field, array $values): self
+ {
+ $this->conditions[] = [$field, 'in', $values];
+ return $this;
+ }
+
+ public function whereNull(string $field): self
+ {
+ $this->conditions[] = [$field, 'null', null];
+ return $this;
+ }
+
+ public function whereRaw(string $sql): self
+ {
+ if (!in_array($sql, ['0 = 1', '(assistant_id IS NULL OR assistant_id = 0)'], true)) {
+ throw new RuntimeException('Unexpected fixture SQL: ' . $sql);
+ }
+ $this->conditions[] = ['', 'raw', $sql];
+ return $this;
+ }
+
+ public function with(array $relations): self { return $this; }
+ public function append(array $fields): self { return $this; }
+ public function orderRaw(string $order): self { return $this; }
+ public function order(string $field, string $direction): self { return $this; }
+ public function group(string $field): self { return $this; }
+ public function getTableFields(): array { return []; }
+
+ public function field($fields): self
+ {
+ $this->fields = is_array($fields) ? $fields : array_map('trim', explode(',', $fields));
+ return $this;
+ }
+
+ public function limit(int $offset, int $length): self
+ {
+ $this->offset = $offset;
+ $this->length = $length;
+ return $this;
+ }
+
+ private function rows(): array
+ {
+ $rows = array_values(array_filter(self::$tables[$this->table] ?? [], function (array $row): bool {
+ foreach ($this->conditions as [$field, $operator, $value]) {
+ $actual = $row[$field] ?? null;
+ if (($operator === '=' && $actual != $value)
+ || ($operator === 'in' && !in_array($actual, $value))
+ || ($operator === 'null' && $actual !== null)
+ || ($operator === 'raw' && ($value === '0 = 1' || ($row['assistant_id'] ?? 0) != 0))) {
+ return false;
+ }
+ }
+ return true;
+ }));
+ return array_slice($rows, $this->offset, $this->length);
+ }
+
+ public function select(): think\Collection
+ {
+ if ($this->table === 'admin') {
+ throw new RuntimeException('Assistant enrichment must not read full admin records');
+ }
+ $rows = $this->rows();
+ if ($this->fields !== null) {
+ $rows = array_map(fn (array $row): array => array_intersect_key($row, array_flip($this->fields)), $rows);
+ }
+ return new think\Collection($rows);
+ }
+
+ public function column(string $field, string $key = ''): array
+ {
+ if ($this->table === 'admin') {
+ self::$adminReads[] = ['conditions' => $this->conditions, 'field' => $field, 'key' => $key];
+ }
+ return array_column($this->rows(), $field, $key !== '' ? $key : null);
+ }
+
+ public function count(): int { return count($this->rows()); }
+}
+
+class AssistantNameModel
+{
+ protected const TABLE = 'empty';
+ public function getTable(): string { return static::TABLE; }
+ public static function where(...$args): AssistantNameQuery
+ {
+ return (new AssistantNameQuery(static::TABLE))->where(...$args);
+ }
+ public static function whereIn(...$args): AssistantNameQuery
+ {
+ return (new AssistantNameQuery(static::TABLE))->whereIn(...$args);
+ }
+}
+final class AssistantNameDiagnosis extends AssistantNameModel { protected const TABLE = 'tcm_diagnosis'; }
+final class AssistantNameAdmin extends AssistantNameModel { protected const TABLE = 'admin'; }
+final class AssistantNameRoles extends AssistantNameModel { protected const TABLE = 'admin_role'; }
+final class AssistantNameAppointment extends AssistantNameModel { protected const TABLE = 'doctor_appointment'; }
+final class AssistantNameDb
+{
+ public static function name(string $table): AssistantNameQuery { return new AssistantNameQuery($table); }
+}
+final class AssistantNameRevisitDue
+{
+ public static function buildListPayload(...$args): array { return []; }
+ public static function emptyPayload(): array { return []; }
+}
+
+class_alias(AssistantNameDiagnosis::class, 'app\\common\\model\\tcm\\Diagnosis');
+class_alias(AssistantNameAdmin::class, 'app\\common\\model\\auth\\Admin');
+class_alias(AssistantNameRoles::class, 'app\\common\\model\\auth\\AdminRole');
+class_alias(AssistantNameAppointment::class, 'app\\common\\model\\doctor\\Appointment');
+foreach (['tcm\\Prescription', 'tcm\\CallRecord', 'tcm\\BloodRecord', 'tcm\\DietRecord',
+ 'tcm\\ExerciseRecord', 'dict\\DictData'] as $model) {
+ class_alias(AssistantNameModel::class, 'app\\common\\model\\' . $model);
+}
+class_alias(AssistantNameDb::class, 'think\\facade\\Db');
+class_alias(AssistantNameRevisitDue::class, 'app\\common\\service\\tcm\\RevisitDueService');
+
+final class AssistantNameLists extends app\adminapi\lists\tcm\DiagnosisLists
+{
+ public function __construct(private ?array $visibleIds = null, array $params = [], int $offset = 0,
+ int $length = 100, int $actor = 91)
+ {
+ $this->params = $params;
+ $this->adminId = $actor;
+ $this->adminInfo = ['admin_id' => $actor];
+ $this->searchWhere = [];
+ $this->limitOffset = $offset;
+ $this->limitLength = $length;
+ }
+ protected function dataScopeShouldApply(): bool { return true; }
+ protected function getDataScopeVisibleAdminIds(): ?array { return $this->visibleIds; }
+}
+
+$assertions = 0;
+function assistantNameExpect(bool $ok, string $message): void
+{
+ global $assertions;
+ if (!$ok) {
+ throw new RuntimeException('FAIL: ' . $message);
+ }
+ $assertions++;
+}
+
+// Input order is already the normal no-appointment descending-id order.
+AssistantNameQuery::$tables = [
+ 'tcm_diagnosis' => [
+ ['id' => 109, 'assistant_id' => 41],
+ ['id' => 108, 'assistant_id' => '42'],
+ ['id' => 107, 'assistant_id' => 41],
+ ['id' => 106, 'assistant_id' => null],
+ ['id' => 105, 'assistant_id' => 0],
+ ['id' => 104, 'assistant_id' => 404],
+ ['id' => 103, 'assistant_id' => 43],
+ ['id' => 102, 'assistant_id' => 44],
+ ['id' => 101, 'assistant_id' => -1],
+ ],
+ 'admin' => [
+ ['id' => 41, 'name' => ' 林医助 ', 'delete_time' => null, 'disable' => 0, 'account' => 'private-41'],
+ ['id' => 42, 'name' => '王医助', 'delete_time' => null, 'disable' => 1, 'account' => 'private-42'],
+ ['id' => 43, 'name' => '已删除医助', 'delete_time' => 123, 'disable' => 0],
+ ['id' => 44, 'name' => ' ', 'delete_time' => null, 'disable' => 0],
+ ['id' => 45, 'name' => '未关联人员', 'delete_time' => null, 'disable' => 0],
+ ],
+];
+$lists = new AssistantNameLists();
+$rows = $lists->lists();
+assistantNameExpect(array_column($rows, 'assistant_name', 'id') === [
+ 109 => '林医助', 108 => '王医助', 107 => '林医助', 106 => '', 105 => '',
+ 104 => '', 103 => '', 102 => '', 101 => '',
+], 'each row gets the assigned name; unassigned, missing, deleted, blank and invalid assignments remain empty');
+assistantNameExpect(AssistantNameQuery::$adminReads === [[
+ 'conditions' => [['id', 'in', [41, 42, 404, 43, 44]], ['delete_time', 'null', null]],
+ 'field' => 'name', 'key' => 'id',
+]], 'one batch reads only positive unique assigned ids and the name/id projection');
+assistantNameExpect($rows[1]['assistant_id'] === '42' && $rows[1]['assistant_name'] === '王医助',
+ 'disabled but still assigned staff retain their name without changing the original id');
+assistantNameExpect(!str_contains(json_encode($rows, JSON_UNESCAPED_UNICODE), 'private-')
+ && !str_contains(json_encode($rows, JSON_UNESCAPED_UNICODE), '未关联人员'),
+ 'enrichment exposes neither admin account fields nor unrelated names');
+assistantNameExpect($lists->count() === count($rows), 'enrichment does not change total count');
+
+AssistantNameQuery::$adminReads = [];
+$scoped = new AssistantNameLists([41]);
+$rows = $scoped->lists();
+assistantNameExpect(array_column($rows, 'id') === [109, 107] && $scoped->count() === 2,
+ 'real data-scope filtering remains active for lists and count');
+assistantNameExpect(AssistantNameQuery::$adminReads[0]['conditions'][0] === ['id', 'in', [41]],
+ 'names outside the authorized rows are never queried');
+
+AssistantNameQuery::$adminReads = [];
+$page = new AssistantNameLists(null, [], 1, 1);
+$rows = $page->lists();
+assistantNameExpect(array_column($rows, 'id') === [108] && $rows[0]['assistant_name'] === '王医助'
+ && $page->count() === 9, 'pagination and count are preserved');
+assistantNameExpect(AssistantNameQuery::$adminReads[0]['conditions'][0] === ['id', 'in', [42]],
+ 'only names on the requested page are queried');
+
+AssistantNameQuery::$tables['admin_role'] = [['admin_id' => 41, 'role_id' => 2]];
+AssistantNameQuery::$adminReads = [];
+$self = new AssistantNameLists(null, [], 0, 100, 41);
+assistantNameExpect(array_column($self->lists(), 'id') === [109, 107] && $self->count() === 2,
+ 'assistant-role self filtering is unchanged');
+assistantNameExpect(AssistantNameQuery::$adminReads[0]['conditions'][0] === ['id', 'in', [41]],
+ 'assistant-role name hydration uses the self-filtered result only');
+AssistantNameQuery::$tables['admin_role'] = [];
+
+foreach ([new AssistantNameLists([]), new AssistantNameLists(null, [], 50, 10)] as $empty) {
+ AssistantNameQuery::$adminReads = [];
+ assistantNameExpect($empty->lists() === [] && AssistantNameQuery::$adminReads === [],
+ 'empty authorization scope or an empty page does not query admin data');
+}
+AssistantNameQuery::$adminReads = [];
+$pending = new AssistantNameLists(null, ['pending_assign' => 1]);
+$rows = $pending->lists();
+assistantNameExpect(array_column($rows, 'id') === [106, 105] && $pending->count() === 2
+ && array_column($rows, 'assistant_name') === ['', ''] && AssistantNameQuery::$adminReads === [],
+ 'unassigned-only lists have an explicit empty name and need no admin query');
+
+echo "Diagnosis assistant names: {$assertions} assertions passed (no DB or network)\n";
diff --git a/server/tests/DiagnosisDepartmentFilterPermissionTest.php b/server/tests/DiagnosisDepartmentFilterPermissionTest.php
new file mode 100644
index 000000000..beb964f4a
--- /dev/null
+++ b/server/tests/DiagnosisDepartmentFilterPermissionTest.php
@@ -0,0 +1,252 @@
+ 1, 'show' => 0, 'msg' => '', 'data' => $data];
+ }
+ }
+}
+
+namespace app\common\model\dept {
+ final class Dept
+ {
+ public static array $rows = [];
+ public static int $reads = 0;
+ public static function order(array $order): self { self::$reads++; return new self(); }
+ public function select(): self { return $this; }
+ public function toArray(): array { return self::$rows; }
+ }
+}
+
+namespace app\common\service\DataScope {
+ final class DataScopeService
+ {
+ public static ?array $allowed = [12 => true];
+ public static array $actors = [];
+ public static function isEnabled(): bool { return true; }
+ public static function getAllowedDeptIdSet(int $adminId, array $adminInfo): ?array
+ {
+ self::$actors[] = [$adminId, $adminInfo];
+ return self::$allowed;
+ }
+ }
+}
+
+namespace app\adminapi\http\middleware {
+ function request(): object { return $GLOBALS['diagnosis_department_request']; }
+ function env(...$arguments): never { throw new \RuntimeException('This test must not read environment configuration'); }
+}
+
+namespace {
+ require dirname(__DIR__) . '/vendor/autoload.php';
+
+ use app\adminapi\controller\dept\DeptController;
+ use app\adminapi\http\middleware\AuthMiddleware;
+ use app\common\cache\AdminAuthCache;
+ use app\common\model\dept\Dept;
+ use app\common\service\DataScope\DataScopeService;
+ use app\common\service\JsonService;
+
+ final class DiagnosisDepartmentRequest
+ {
+ public array $adminInfo;
+ public object $controllerObject;
+ public string $controllerName = 'dept.Dept';
+ public string $actionName = 'all';
+
+ public function __construct(public array $query = [], public string $method = 'GET',
+ public array $body = [], bool $root = false)
+ {
+ $this->adminInfo = ['admin_id' => 91, 'root' => $root ? 1 : 0, 'role_id' => [7],
+ 'login_ip' => '192.0.2.91', 'work_wechat_userid' => 'synthetic-doctor-91'];
+ }
+ public function get(?string $name = null, $default = null)
+ {
+ return $name === null ? $this->query : ($this->query[$name] ?? $default);
+ }
+ public function param(string $name, $default = null)
+ {
+ return $this->body[$name] ?? $this->query[$name] ?? $default;
+ }
+ public function isGet(): bool { return $this->method === 'GET'; }
+ public function controller(): string { return $this->controllerName; }
+ public function action(): string { return $this->actionName; }
+ public function ip(): string { return '192.0.2.91'; }
+ }
+
+ final class DiagnosisDepartmentController extends DeptController
+ {
+ public function __construct(DiagnosisDepartmentRequest $request)
+ {
+ $this->request = $request;
+ $this->adminId = $request->adminInfo['admin_id'];
+ $this->adminInfo = $request->adminInfo;
+ }
+ protected function data($data) { return JsonService::data($data); }
+ }
+
+ $assertions = 0;
+ function departmentPermissionExpect(bool $ok, string $label): void
+ {
+ global $assertions;
+ if (!$ok) { throw new RuntimeException('FAIL: ' . $label); }
+ $assertions++;
+ }
+
+ function departmentPermissionInvoke(DiagnosisDepartmentRequest $request, array $permissions,
+ ?array $registered = null): array
+ {
+ AdminAuthCache::$permissions = $permissions;
+ AdminAuthCache::$allUris = $registered ?? [
+ 'dept.dept/all', 'dept.dept/lists', 'dept.dept/leaderDept', 'dept.dept/detail',
+ 'dept.dept/add', 'dept.dept/edit', 'dept.dept/delete', 'dept.jobs/all',
+ ];
+ AdminAuthCache::$actors = [];
+ AdminAuthCache::$allUriReads = 0;
+ DataScopeService::$actors = [];
+ Dept::$reads = 0;
+ $GLOBALS['diagnosis_department_request'] = $request;
+ $controller = new DiagnosisDepartmentController($request);
+ $request->controllerObject = $controller;
+ return (new AuthMiddleware())->handle($request, static function () use ($request, $controller): array {
+ if (strtolower($request->controller() . '/' . $request->action()) === 'dept.dept/all') {
+ return $controller->all();
+ }
+ // A sentinel verifies function-level grants without performing CRUD writes.
+ return ['code' => 1, 'continued' => true];
+ });
+ }
+
+ Dept::$rows = [
+ ['id' => 10, 'pid' => 0, 'name' => '共享祖先'],
+ ['id' => 11, 'pid' => 10, 'name' => '未授权同级'],
+ ['id' => 12, 'pid' => 10, 'name' => '授权部门'],
+ ['id' => 20, 'pid' => 0, 'name' => '其他部门'],
+ ];
+ $scopedTree = [[
+ 'id' => 10, 'pid' => 0, 'name' => '共享祖先', 'level' => 0,
+ 'children' => [['id' => 12, 'pid' => 10, 'name' => '授权部门', 'level' => 1, 'children' => []]],
+ ]];
+
+ foreach ([1, '1'] as $scope) {
+ foreach (['tcm.diagnosis/lists', 'TCM.DIAGNOSIS/LISTS'] as $permission) {
+ $request = new DiagnosisDepartmentRequest(['apply_data_scope' => $scope]);
+ $request->controllerName = 'DePt.DePt';
+ $request->actionName = 'AlL';
+ $result = departmentPermissionInvoke($request, [$permission]);
+ departmentPermissionExpect($result === ['code' => 1, 'show' => 0, 'msg' => '', 'data' => $scopedTree],
+ 'exact patient-list grant returns the real scoped tree and original envelope');
+ departmentPermissionExpect(DataScopeService::$actors === [[91, $request->adminInfo]],
+ 'controller passes the authenticated identity to data scope');
+ departmentPermissionExpect(Dept::$reads === 1 && AdminAuthCache::$allUriReads === 0,
+ 'read-only alias does not need a department management grant');
+ }
+ }
+
+ $invalidQueries = [[], ['apply_data_scope' => null]];
+ foreach ([0, '0', '', '01', '1x', '1.0', ' 1', '1 ', true, false, 1.0, 2, -1,
+ [], ['1'], new stdClass()] as $invalid) {
+ $invalidQueries[] = ['apply_data_scope' => $invalid];
+ }
+ foreach ($invalidQueries as $query) {
+ $result = departmentPermissionInvoke(new DiagnosisDepartmentRequest($query), ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($result['code'] === 0, 'missing or malformed scope cannot acquire the alias ' . json_encode($query));
+ departmentPermissionExpect(Dept::$reads === 0 && DataScopeService::$actors === [],
+ 'invalid scope is rejected before any department read');
+ }
+
+ foreach ([[], ['tcm.diagnosis/detail'], ['tcm.diagnosis/edit'], ['tcm.diagnosis/listsOther'],
+ ['dept.dept/lists']] as $permissions) {
+ $result = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => '1']), $permissions);
+ departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0,
+ 'unrelated permissions do not acquire the patient-list alias ' . json_encode($permissions));
+ }
+
+ foreach (['POST', 'PUT', 'DELETE', 'HEAD'] as $method) {
+ $result = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => '1'], $method),
+ ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0, 'alias denies HTTP method ' . $method);
+ }
+ foreach (['GET', 'POST'] as $method) {
+ $result = departmentPermissionInvoke(new DiagnosisDepartmentRequest([], $method, ['apply_data_scope' => '1']),
+ ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0,
+ 'request body cannot enable GET-only scope on ' . $method);
+ }
+
+ foreach (['lists', 'leaderDept', 'detail', 'add', 'edit', 'delete'] as $action) {
+ foreach (['GET', 'POST'] as $method) {
+ $request = new DiagnosisDepartmentRequest(['apply_data_scope' => '1'], $method);
+ $request->actionName = $action;
+ $result = departmentPermissionInvoke($request, ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0,
+ 'patient-list grant never authorizes department action ' . $method . ' ' . $action);
+ }
+ $request = new DiagnosisDepartmentRequest([], 'POST');
+ $request->actionName = $action;
+ $result = departmentPermissionInvoke($request, ['dept.dept/' . $action]);
+ departmentPermissionExpect(($result['continued'] ?? false) === true,
+ 'original exact department permission still authorizes ' . $action);
+ }
+ $otherController = new DiagnosisDepartmentRequest(['apply_data_scope' => '1']);
+ $otherController->controllerName = 'dept.Jobs';
+ $result = departmentPermissionInvoke($otherController, ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($result['code'] === 0, 'alias is limited to the exact department controller');
+
+ $originalGrant = departmentPermissionInvoke(new DiagnosisDepartmentRequest(), ['dept.dept/all']);
+ departmentPermissionExpect($originalGrant['code'] === 1 && count($originalGrant['data']) === 2
+ && count($originalGrant['data'][0]['children']) === 2 && DataScopeService::$actors === [],
+ 'original all-departments permission retains its unscoped response');
+ $originalScoped = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => 1]), ['dept.dept/all']);
+ departmentPermissionExpect($originalScoped['data'] === $scopedTree && count(DataScopeService::$actors) === 1,
+ 'original department grant can still request a scoped tree');
+
+ $root = departmentPermissionInvoke(new DiagnosisDepartmentRequest([], 'GET', [], true), []);
+ departmentPermissionExpect($root['code'] === 1 && count($root['data']) === 2 && AdminAuthCache::$actors === [],
+ 'root retains the existing middleware bypass and full tree');
+ DataScopeService::$allowed = null;
+ $rootScoped = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => 1], 'GET', [], true), []);
+ departmentPermissionExpect($rootScoped['data'] === $root['data'] && count(DataScopeService::$actors) === 1,
+ 'root scoped requests still execute the controller data-scope contract');
+ DataScopeService::$allowed = [];
+ $empty = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => '1']), ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($empty === ['code' => 1, 'show' => 0, 'msg' => '', 'data' => []],
+ 'authorized empty scope remains a successful empty tree, never a full-data fallback');
+
+ $legacy = departmentPermissionInvoke(new DiagnosisDepartmentRequest(), [], []);
+ departmentPermissionExpect($legacy['code'] === 1 && count($legacy['data']) === 2 && AdminAuthCache::$allUriReads === 1,
+ 'unmatched requests continue through the existing unregistered-route policy unchanged');
+
+ echo "Diagnosis department filter permission: {$assertions} assertions passed (no DB or network)\n";
+}