erge branch '11-5' of http://git.zhenyangtang.com.cn/ZYT/zyt

it stash pop
This commit is contained in:
gr
2026-11-06 12:02:54 +08:00
20 changed files with 2338 additions and 36 deletions
+1
View File
@@ -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",
@@ -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)
@@ -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",
@@ -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]:
@@ -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)
@@ -31,6 +31,7 @@ from PySide6.QtWidgets import (
QFrame,
QGridLayout,
QHBoxLayout,
QHeaderView,
QInputDialog,
QLabel,
QMessageBox,
@@ -48,6 +49,7 @@ from PySide6.QtWidgets import (
)
from ...core.appointment_modes import appointment_type_description, appointment_type_value
from ...core.errors import ApiTimeoutError, ApiTransportError
from ...core.transcript_text import simplified_transcript
from ..design_system import clinical_stylesheet
from ..diagnosis_drawer import (
@@ -105,6 +107,7 @@ class _UploadProgressRelay(QObject):
# 用 object 传字节数:Qt 的 int 是 32 位,超过 2 GB 的视频会溢出。
progressed = Signal(object, object)
processing = Signal()
_PHONE_PERMISSION = "tcm.diagnosis/phonePlain"
@@ -975,6 +978,7 @@ class DiagnosisDialog(QDialog):
self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.readonly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.readonly_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.readonly_scroll.viewport().installEventFilter(self)
self.readonly_scroll.verticalScrollBar().setSingleStep(28)
content = QWidget()
self.readonly_content_layout = QVBoxLayout(content)
@@ -999,6 +1003,25 @@ class DiagnosisDialog(QDialog):
self.readonly_content_layout.addWidget(self.readonly_error)
self.readonly_patient_card = self._build_readonly_patient_card()
self.readonly_content_layout.addWidget(self.readonly_patient_card)
self.readonly_prescriptions_table = self._new_table(
"prescription", "DiagnosisReadonlyPrescriptionsTable"
)
# Keep the open action visible beside the editor; full prescription
# contents remain available through the existing detail dialog.
self.readonly_prescriptions_table.setColumnHidden(1, True)
self.readonly_prescriptions_table.setColumnHidden(3, True)
self.readonly_prescriptions_table.horizontalHeaderItem(0).setText("开方日期")
self.readonly_prescriptions_table.horizontalHeaderItem(2).setText("临床诊断")
for column, width in ((0, 100), (4, 70), (5, 84), (6, 64)):
self.readonly_prescriptions_table.setColumnWidth(column, width)
self.readonly_prescriptions_table.horizontalHeader().setSectionResizeMode(
2, QHeaderView.ResizeMode.Stretch
)
self.readonly_prescriptions_table.setMinimumHeight(220)
self._add_readonly_section("prescription", "历史处方", self.readonly_prescriptions_table)
self.readonly_content_layout.setAlignment(
self._readonly_sections["prescription"], Qt.AlignmentFlag.AlignLeft
)
self.case_grid = CaseGrid()
self.readonly_content_layout.addWidget(self.case_grid)
@@ -1991,6 +2014,17 @@ class DiagnosisDialog(QDialog):
self._sync_host_geometry()
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt API
if (
hasattr(self, "readonly_scroll")
and watched is self.readonly_scroll.viewport()
and event.type() == QEvent.Type.Resize
and "prescription" in self._readonly_sections
):
# Other patient cards can have a wider minimum size; the history
# actions must still fit inside the visible side pane.
self._readonly_sections["prescription"].setFixedWidth(
max(0, self.readonly_scroll.viewport().width() - 32)
)
if watched is self._owner and event.type() in {
QEvent.Type.Resize,
QEvent.Type.Move,
@@ -2745,7 +2779,7 @@ class DiagnosisDialog(QDialog):
def _load_visible_readonly_sections(self) -> None:
permission_map = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
for key in ("notes", "daily", "orders", "video", "chat", "assign", "appointment"):
for key in ("prescription", "notes", "daily", "orders", "video", "chat", "assign", "appointment"):
if self._tab_allowed(permission_map.get(key, ())):
self._ensure_tab_loaded(key)
@@ -3187,6 +3221,27 @@ class DiagnosisDialog(QDialog):
"offset": self._offset_generation,
}[kind]
if generation == current and diagnosis_id == self._diagnosis_id:
error_data = getattr(error, "data", None)
endpoint = error_data.get("endpoint") if isinstance(error_data, Mapping) else None
if (
kind == "media" and isinstance(error, ApiTransportError)
and isinstance(error_data, Mapping) and error_data.get("provider") == "cos"
):
self._show_message(str(error), "danger")
return
if (
kind == "media" and isinstance(error, ApiTimeoutError)
and getattr(self, "_video_upload_relay", None) is not None
and endpoint in {
"upload/ossConfirm", "tcm.diagnosis/createManualCallRecord",
"tcm.diagnosis/attachLocalCallRecording",
}
):
self._show_message(
"视频已上传,等待保存回放记录超时。请稍后重新打开本页核对结果,避免立即重复上传。",
"warning",
)
return
self._show_message(friendly_error(error), "danger")
def _add_doctor_note(self) -> None:
@@ -3365,6 +3420,9 @@ class DiagnosisDialog(QDialog):
def _upload_call_recording(self, call_record_id: int | None = None) -> None:
if not self._editable or not self._can_video_upload:
return
if getattr(self, "_video_upload_relay", None) is not None:
self._show_message("当前回放正在上传,请等待完成。", "info")
return
if call_record_id is not None and call_record_id <= 0:
self._show_message("通话记录 ID 无效,无法追加回放。", "warning")
return
@@ -3381,8 +3439,16 @@ class DiagnosisDialog(QDialog):
if not mime_type.startswith("video/"):
mime_type = "video/mp4"
file_name = Path(path).name
diagnosis_id = self._diagnosis_id
relay = _UploadProgressRelay(self)
relay.progressed.connect(lambda sent, total: self._video_upload_progressed(file_name, sent, total))
relay.progressed.connect(
lambda sent, total: self._video_upload_progressed(file_name, sent, total)
if getattr(self, "_video_upload_relay", None) is relay else None
)
relay.processing.connect(
lambda: self._video_upload_processing(file_name)
if getattr(self, "_video_upload_relay", None) is relay else None
)
self._video_upload_relay = relay
self._video_upload_progressed(file_name, 0, 0)
self._run_media_mutation(
@@ -3390,10 +3456,11 @@ class DiagnosisDialog(QDialog):
self.repository,
"upload_call_recording",
path=path,
diagnosis_id=self._diagnosis_id,
diagnosis_id=diagnosis_id,
call_record_id=call_record_id,
mime_type=mime_type,
on_progress=relay.progressed.emit,
on_processing=relay.processing.emit,
),
"视频回放已追加到指定通话记录。"
if call_record_id is not None
@@ -3416,13 +3483,22 @@ class DiagnosisDialog(QDialog):
percent = int(ratio * 100)
mb = 1024 * 1024
if sent >= total:
text = f"「{file_name}」已上传完成({total / mb:.1f} MB),正在生成通话记录…"
text = f"「{file_name}」已上传并保存({total / mb:.1f} MB),正在刷新回放列表…"
else:
text = f"正在上传「{file_name}」 {percent}%({sent / mb:.1f} / {total / mb:.1f} MB),请勿关闭窗口"
self.video_upload_status.setText(text)
self.video_upload_button.setText(f"上传中 {percent}%")
self._show_message(text, "info")
def _video_upload_processing(self, file_name: str) -> None:
self.video_upload_frame.show()
self.video_upload_bar.setRange(0, 0)
self.video_upload_button.setEnabled(False)
self.video_upload_button.setText("正在保存回放…")
text = f"「{file_name}」已上传,正在保存回放记录,请勿关闭窗口…"
self.video_upload_status.setText(text)
self._show_message(text, "info")
def _video_upload_finished(self, relay: QObject) -> None:
if getattr(self, "_video_upload_relay", None) is not relay:
return
@@ -3809,12 +3885,15 @@ class DiagnosisDialog(QDialog):
first_value(row, "doctor_name"),
"已作废"
if voided
else first_value(row, "status_text", "audit_status_text", default="正常"),
else first_value(
row, "status_text", "audit_status_text",
default=_audit_text(first_value(row, "audit_status", default=None)),
),
"",
)
)
for table in self._table_registry["prescription"]:
table.set_empty_text("暂无病历记录,开方后会自动显示。")
table.set_empty_text("暂无历史处方,开方后会自动显示。")
table.set_matrix(matrix)
self._semanticize(table, 5)
for row_index, row in enumerate(rows):
@@ -3831,6 +3910,7 @@ class DiagnosisDialog(QDialog):
voided = _truthy(first_value(row, "void_status", "is_void", default=False))
can_edit = (
self._editable
and table is not self.readonly_prescriptions_table
and self._can_edit_prescription
and (not approved or voided)
)
@@ -3914,6 +3994,10 @@ class DiagnosisDialog(QDialog):
if actual_id != prescription_id:
self._show_message("服务端处方与当前行不一致,已停止打开。", "danger")
return
detail_diagnosis_id = _int(first_value(prescription, "diagnosis_id", default=0), 0)
if self._diagnosis_id > 0 and detail_diagnosis_id != self._diagnosis_id:
self._show_message("该处方不属于当前诊单,已停止打开。", "danger")
return
self._clear_message()
if edit:
self._open_prescription_edit_dialog(prescription, prescription_id)
@@ -40,6 +40,7 @@ from PySide6.QtWidgets import (
from ...core import UserProfile
from ...core.appointment_modes import appointment_type_value, can_appointment_video
from ...core.errors import ApiBusinessError
from .. import icons
from ..consultations_style import consultations_stylesheet
from ..diagnosis_index_widgets import (
@@ -1643,13 +1644,6 @@ class ConsultationsPage(QWidget):
return combo.currentText().strip()
return "" if data is None else data
def _assistant_cell(self, _value: Any, row: Any) -> str:
name = first_value(row, "assistant_name", default="")
if name:
return display_text(name)
assistant_id = _as_int(first_value(row, "assistant_id", "assistant", default=0))
return self._assistant_names.get(assistant_id, "—")
def _custom_date_changed(self, value: QDate) -> None:
if value == _OPTIONAL_DATE_MINIMUM:
return
@@ -2235,9 +2229,15 @@ class ConsultationsPage(QWidget):
self._departments_loading = False
self._departments_loaded = False
self.department_combo.clear()
self.department_combo.addItem("部门加载失败", "")
permission_denied = isinstance(error, ApiBusinessError) and any(
text in str(error) for text in ("权限不足", "无权限", "无权")
)
self.department_combo.addItem("暂无部门筛选权限" if permission_denied else "部门加载失败", "")
self.department_combo.setEnabled(False)
message = f"部门加载失败:{friendly_error(error)}。点击重试。"
message = (
"当前账号无权读取部门,请联系管理员检查部门筛选权限,处理后点击重试。"
if permission_denied else f"部门加载失败:{friendly_error(error)}。点击重试。"
)
self.department_combo.setToolTip(message)
self.department_retry_button.setToolTip(message)
self.department_retry_button.show()
@@ -2320,11 +2320,12 @@ class ConsultationsPage(QWidget):
combo.setEditText(str(current))
self._assistant_names = {
_as_int(first_value(row, "id", "value")): display_text(
first_value(row, "name", "label")
first_value(row, "name", "label"), ""
)
for row in options.get("assistants", [])
if _as_int(first_value(row, "id", "value")) > 0
}
self.table_host.set_assistant_names(self._assistant_names)
def _checked_changed(self, count: int) -> None:
self.selected_label.setText(f"已选 {count} 条")
@@ -2418,6 +2419,7 @@ class ConsultationsPage(QWidget):
}
options = {key: value for key, value in options.items() if value}
self._assistant_names.update(options)
self.table_host.set_assistant_names(self._assistant_names)
self._show_assistant_picker(records, options)
def _show_assistant_picker(
@@ -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))
@@ -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() == "—"
+16 -3
View File
@@ -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]] = []
+352
View File
@@ -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"]
@@ -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,
+110 -2
View File
@@ -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,
+88
View File
@@ -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)
+144
View File
@@ -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"<ListMultipartUploadsResult><IsTruncated>false</IsTruncated></ListMultipartUploadsResult>"
elif request.method == "POST" and "uploads" in query:
body = b"<InitiateMultipartUploadResult><UploadId>synthetic-upload</UploadId></InitiateMultipartUploadResult>"
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'<CompleteMultipartUploadResult><ETag>"synthetic-etag"</ETag></CompleteMultipartUploadResult>'
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)
Generated
+247
View File
@@ -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" },
@@ -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') {
@@ -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')));
+261
View File
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
// Exercise the real lists/count methods with synthetic in-memory rows only.
require dirname(__DIR__) . '/vendor/autoload.php';
final class AssistantNameQuery
{
public static array $tables = [];
public static array $adminReads = [];
private array $conditions = [];
private ?array $fields = null;
private int $offset = 0;
private ?int $length = null;
public function __construct(private string $table) {}
public function where($field, $operator = null, $value = null): self
{
if (is_array($field)) {
foreach ($field as $condition) {
$this->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";
@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
// Execute the real middleware, department controller and tree filtering logic.
// Only authentication storage, department rows and scope lookup are in memory.
namespace app\adminapi\logic {
final class LoginLogic
{
public static function adminMustBindWorkWechat(array $adminInfo): bool { return false; }
}
}
namespace app\common\cache {
final class AdminAuthCache
{
public static array $permissions = [];
public static array $allUris = [];
public static array $actors = [];
public static int $allUriReads = 0;
public function __construct(int $adminId) { self::$actors[] = $adminId; }
public function getAdminUri(): array { return self::$permissions; }
public function getAllUri(): array { self::$allUriReads++; return self::$allUris; }
}
}
namespace app\common\service {
final class JsonService
{
public static function fail(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): array
{
return compact('code', 'show', 'msg', 'data');
}
public static function data(array $data): array
{
return ['code' => 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";
}