From 0d36a6e840e76ad481d1bd0f046953155f515ae6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 5 Nov 2026 17:58:18 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pyproject.toml | 1 + .../services/mock_repository.py | 5 + .../doctor_workstation/services/repository.py | 58 +++- .../ui/dialogs/diagnosis.py | 50 +++- .../test_diagnosis_order_video_visual.py | 68 +++++ app/uv.lock | 247 ++++++++++++++++++ 6 files changed, 422 insertions(+), 7 deletions(-) diff --git a/app/pyproject.toml b/app/pyproject.toml index 9fcd95be8..06cb91a78 100644 --- 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/mock_repository.py b/app/src/doctor_workstation/services/mock_repository.py index 10e8cfea6..abdcd18ad 100644 --- 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 100644 --- 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/dialogs/diagnosis.py b/app/src/doctor_workstation/ui/dialogs/diagnosis.py index 580153723..2fe3bbb78 100644 --- a/app/src/doctor_workstation/ui/dialogs/diagnosis.py +++ b/app/src/doctor_workstation/ui/dialogs/diagnosis.py @@ -48,6 +48,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 +106,7 @@ class _UploadProgressRelay(QObject): # 用 object 传字节数:Qt 的 int 是 32 位,超过 2 GB 的视频会溢出。 progressed = Signal(object, object) + processing = Signal() _PHONE_PERMISSION = "tcm.diagnosis/phonePlain" @@ -3187,6 +3189,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 +3388,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 +3407,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 +3424,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 +3451,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 diff --git a/app/tests/test_diagnosis_order_video_visual.py b/app/tests/test_diagnosis_order_video_visual.py index ef36eccef..9fe68e466 100644 --- 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/uv.lock b/app/uv.lock index 342abd964..32c5a4776 100644 --- 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" }, From 5926083221d1b700b2de0aa91c608a99c2887d74 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 6 Nov 2026 11:48:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/dialogs/diagnosis.py | 46 +++- .../ui/pages/consultations.py | 11 +- .../ui/pages/prescriptions.py | 214 +++++++++++++++++- app/tests/test_consultation_departments.py | 19 +- app/tests/test_prescription_security_ui.py | 112 ++++++++- app/tests/test_prescriptions_tech_blue.py | 88 +++++++ .../http/middleware/AuthMiddleware.php | 11 + 7 files changed, 483 insertions(+), 18 deletions(-) diff --git a/app/src/doctor_workstation/ui/dialogs/diagnosis.py b/app/src/doctor_workstation/ui/dialogs/diagnosis.py index 2fe3bbb78..8f749273e 100644 --- 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, @@ -977,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) @@ -1001,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) @@ -1993,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, @@ -2747,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) @@ -3853,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): @@ -3875,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) ) @@ -3958,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..e6b3d832f 100644 --- 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 ( @@ -2235,9 +2236,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() diff --git a/app/src/doctor_workstation/ui/pages/prescriptions.py b/app/src/doctor_workstation/ui/pages/prescriptions.py index 5a0a65f83..069a3769a 100644 --- 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_departments.py b/app/tests/test_consultation_departments.py index a2ff59c4f..4d61d447f 100644 --- 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_prescription_security_ui.py b/app/tests/test_prescription_security_ui.py index 024e096a5..aaf37f840 100644 --- 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 100644 --- 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/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') { From b46fb901ca379483f1ed6d7973b84590878fbbcb Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 6 Nov 2026 12:01:31 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doctor_workstation/services/cos_upload.py | 223 +++++++++++ .../ui/diagnosis_index_widgets.py | 47 ++- .../ui/pages/consultations.py | 11 +- .../test_consultation_assistant_names.py | 139 +++++++ app/tests/test_cos_upload.py | 352 ++++++++++++++++++ app/tests/test_recording_cos_repository.py | 144 +++++++ .../app/adminapi/lists/tcm/DiagnosisLists.php | 15 + server/tests/DiagnosisAssistantNameTest.php | 261 +++++++++++++ ...iagnosisDepartmentFilterPermissionTest.php | 252 +++++++++++++ 9 files changed, 1433 insertions(+), 11 deletions(-) create mode 100644 app/src/doctor_workstation/services/cos_upload.py create mode 100644 app/tests/test_consultation_assistant_names.py create mode 100644 app/tests/test_cos_upload.py create mode 100644 app/tests/test_recording_cos_repository.py create mode 100644 server/tests/DiagnosisAssistantNameTest.php create mode 100644 server/tests/DiagnosisDepartmentFilterPermissionTest.php 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/ui/diagnosis_index_widgets.py b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py index a5a5e39f9..d97b109b5 100644 --- 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/pages/consultations.py b/app/src/doctor_workstation/ui/pages/consultations.py index e6b3d832f..e63621d7c 100644 --- a/app/src/doctor_workstation/ui/pages/consultations.py +++ b/app/src/doctor_workstation/ui/pages/consultations.py @@ -1644,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 @@ -2327,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} 条") @@ -2425,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/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_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_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/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"; +}