From ef0f95558577d5fb2b68e06dde56a45fb887d480 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Fri, 16 Oct 2026 11:19:18 +0300 Subject: [PATCH] fix --- .gitignore | 3 +- app/opencloud_access.py | 285 ++++++++++++++++++++++++++-- app/pipeline/service.py | 129 +++++++++++-- main.py | 227 ++++++++++++++++++++-- tests/smoke/test_batch_links.py | 162 ++++++++++++++++ tests/smoke/test_batch_smoke.py | 24 ++- tests/unit/test_main_attachments.py | 89 ++++++++- tests/unit/test_opencloud_access.py | 6 +- tests/unit/test_opencloud_links.py | 217 +++++++++++++++++++++ tests/unit/test_opencloud_quota.py | 129 +++++++++++++ 10 files changed, 1210 insertions(+), 61 deletions(-) create mode 100644 tests/smoke/test_batch_links.py create mode 100644 tests/unit/test_opencloud_links.py create mode 100644 tests/unit/test_opencloud_quota.py diff --git a/.gitignore b/.gitignore index df9c564..29a8c18 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ .ruff_cache/ .env -.env.example.superpowers/ +.env.example +.superpowers/ .bounded .DS_Store diff --git a/app/opencloud_access.py b/app/opencloud_access.py index 1383d1c..8c39827 100644 --- a/app/opencloud_access.py +++ b/app/opencloud_access.py @@ -1,6 +1,9 @@ from __future__ import annotations +import logging import xml.etree.ElementTree as ET +from collections.abc import Mapping +from dataclasses import dataclass from pathlib import Path from urllib.parse import quote, unquote, urlparse @@ -20,13 +23,64 @@ _CONTENT_TYPES = { } _DEFAULT_CONTENT_TYPE = "application/octet-stream" +_OC_NAMESPACE = "http://owncloud.org/ns" +# Свойства, по которым OpenCloud (как и ownCloud) отдаёт ссылку для +# веб-интерфейса: oc:privatelink — готовая ссылка, oc:fileid — ID, из которого +# её можно собрать (см. build_opencloud_private_link). +_PRIVATE_LINK_PROPFIND_BODY = ( + '' + f'' + "" + "" +) + +logger = logging.getLogger(__name__) + class OpenCloudUploadError(RuntimeError): """Ошибка загрузки файла в OpenCloud по WebDAV.""" +@dataclass(frozen=True) +class DriveQuota: + """Квота личного пространства в байтах (поле quota диска в Graph API).""" + + used: int + total: int | None = None + remaining: int | None = None + + +@dataclass(frozen=True) +class PersonalDrive: + id: str + quota: DriveQuota | None = None + # driveAlias из Graph API ("personal/<логин>") — часть пути к пространству + # в веб-интерфейсе: /files/spaces/personal/<логин>/... + alias: str | None = None + + +@dataclass(frozen=True) +class OpenCloudUploadResult: + """Куда загружен файл. + + dav_url — WebDAV-адрес (/dav/spaces/...): по нему файл грузили, его же + пишем в протокол. Пользователю он бесполезен: браузер по нему просит + логин или скачивает файл. web_url — ссылка вида https://<домен>/f/, + которая открывает файл в веб-интерфейсе OpenCloud; None, если её не + удалось получить. + """ + + dav_url: str + web_url: str | None = None + + def resolve_personal_drive_id(username: str, token: str) -> str: - """Находит ID личного пространства пользователя через Graph API. + """ID личного пространства пользователя — см. resolve_personal_drive.""" + return resolve_personal_drive(username, token).id + + +def resolve_personal_drive(username: str, token: str) -> PersonalDrive: + """Находит личное пространство пользователя через Graph API. Больше не грузим файлы в общее тестовое пространство по фиксированному SPACE_ID — у каждого заказчика есть личное пространство (driveType == @@ -74,12 +128,49 @@ def resolve_personal_drive_id(username: str, token: str) -> str: f"пользователя '{username}' в OpenCloud." ) - drive_id = personal_drives[0].get("id") + personal_drive = personal_drives[0] + drive_id = personal_drive.get("id") if not drive_id: raise OpenCloudUploadError( "Личное пространство найдено в OpenCloud, но у него отсутствует id." ) - return str(drive_id) + drive_alias = personal_drive.get("driveAlias") + return PersonalDrive( + id=str(drive_id), + quota=_parse_drive_quota(personal_drive.get("quota")), + alias=str(drive_alias) if drive_alias else None, + ) + + +def _parse_drive_quota(raw_quota: object) -> DriveQuota | None: + """Разбирает поле quota диска из Graph API (used/total/remaining, байты). + + Наличие и формат поля на нашем OpenCloud не проверены, поэтому всё, что не + похоже на квоту, даёт None — квота нужна только для отображения и не + должна ломать поиск пространства перед загрузкой. + """ + if not isinstance(raw_quota, Mapping): + logger.info("OpenCloud не вернул quota у личного пространства: %r", raw_quota) + return None + used = _non_negative_int(raw_quota.get("used")) + if used is None: + logger.info("В quota личного пространства нет поля used: %r", raw_quota) + return None + total = _non_negative_int(raw_quota.get("total")) + return DriveQuota( + used=used, + # 0 в total — квота не задана (безлимит), а не «0 байт». + total=total or None, + remaining=_non_negative_int(raw_quota.get("remaining")), + ) + + +def _non_negative_int(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int | float): + return None + if value < 0: + return None + return int(value) def next_opencloud_run_number( @@ -142,6 +233,158 @@ def next_opencloud_run_number( return max(numbers, default=0) + 1 +def build_opencloud_folder_url( + space_id: str, folder_segments: tuple[str, ...] | list[str] +) -> str: + """WebDAV-адрес папки /dav/spaces/{SPACE_ID}/{FOLDER}; сегменты кодируются + так же, как в build_opencloud_url.""" + if not space_id: + raise OpenCloudUploadError("Не задан SPACE_ID для OpenCloud.") + encoded_folder = "/".join( + quote(segment, safe="") for segment in folder_segments if segment + ) + path = f"/dav/spaces/{space_id}" + if encoded_folder: + path += f"/{encoded_folder}" + return f"https://{OPENCLOUD_DOMAIN}{path}" + + +def build_opencloud_web_folder_url( + drive_alias: str, + folder_segments: tuple[str, ...] | list[str], + file_id: str | None = None, +) -> str: + """Ссылка на папку в веб-интерфейсе OpenCloud. + + Формат взят из адресной строки браузера на нашем OpenCloud (29.09): + https://<домен>/files/spaces/personal/<логин>/Out/2026/9/29/1?fileId=... + Собирается без запросов к серверу, поэтому есть всегда. fileId папки + (если известен) добавляется так же, как его добавляет сам интерфейс. + """ + encoded_folder = "/".join( + quote(segment, safe="") for segment in folder_segments if segment + ) + url = f"https://{OPENCLOUD_DOMAIN}/files/spaces/{quote(drive_alias, safe='/')}" + if encoded_folder: + url += f"/{encoded_folder}" + if file_id: + url += f"?fileId={_encode_file_id(file_id)}" + return url + + +def build_opencloud_private_link(file_id: str) -> str: + """Ссылка вида https://<домен>/f/ на файл или папку. + + Такую же ссылку OpenCloud отдаёт в oc:privatelink: она открывает ресурс в + веб-интерфейсе (с обычным входом через браузер), а не скачивает его. + """ + return f"https://{OPENCLOUD_DOMAIN}/f/{_encode_file_id(file_id)}" + + +def _encode_file_id(file_id: str) -> str: + # fileid в OpenCloud составной ("storageId$spaceId!opaqueId"); веб-интерфейс + # кодирует его целиком: "$" -> %24, "!" -> %21. + return quote(file_id, safe="") + + +def resolve_opencloud_web_link(dav_url: str, username: str, token: str) -> str | None: + """Ссылка на ресурс в веб-интерфейсе OpenCloud через PROPFIND (Depth: 0). + + privatelink, если он пришёл, возвращается как есть, иначе ссылка + собирается из fileid. Исключений не бросает (см. _propfind_link_properties): + при неудаче возвращает None, и вызывающий код подставляет запасную ссылку. + """ + properties = _propfind_link_properties(dav_url, username, token) + if properties is None: + return None + private_link, file_id = properties + if private_link: + return private_link + if file_id: + return build_opencloud_private_link(file_id) + logger.warning( + "PROPFIND не вернул ни oc:privatelink, ни oc:fileid: url='%s'", dav_url + ) + return None + + +def resolve_opencloud_file_id(dav_url: str, username: str, token: str) -> str | None: + """oc:fileid ресурса через PROPFIND (Depth: 0); None, если не удалось.""" + properties = _propfind_link_properties(dav_url, username, token) + if properties is None: + return None + _, file_id = properties + if not file_id: + logger.warning("PROPFIND не вернул oc:fileid: url='%s'", dav_url) + return file_id + + +def _propfind_link_properties( + dav_url: str, username: str, token: str +) -> tuple[str | None, str | None] | None: + """(oc:privatelink, oc:fileid) ресурса; None, если PROPFIND не удался. + + Исключений не бросает: ссылки нужны только для удобства, и их отсутствие + не должно ронять обработку — любую проблему пишем в лог. + """ + try: + response = requests.request( + "PROPFIND", + dav_url, + auth=HTTPBasicAuth(username, token), + headers={"Depth": "0", "Content-Type": "application/xml; charset=utf-8"}, + data=_PRIVATE_LINK_PROPFIND_BODY.encode("utf-8"), + timeout=OPENCLOUD_UPLOAD_TIMEOUT_SECONDS, + ) + except requests.RequestException as exc: + logger.warning("PROPFIND за ссылкой не удался: url='%s', %s", dav_url, exc) + return None + + if response.status_code != 207: + logger.warning( + "PROPFIND за ссылкой вернул HTTP %s: url='%s'", + response.status_code, + dav_url, + ) + return None + + try: + root = ET.fromstring(response.content) + except ET.ParseError as exc: + logger.warning( + "Не удалось разобрать ответ PROPFIND за ссылкой: url='%s', %s", + dav_url, + exc, + ) + return None + + # Отсутствующие свойства сервер возвращает пустыми элементами в propstat + # со статусом 404 — пустой текст означает «значения нет». + return _find_oc_property(root, "privatelink"), _find_oc_property(root, "fileid") + + +def _find_oc_property(root: ET.Element, name: str) -> str | None: + for element in root.iter(f"{{{_OC_NAMESPACE}}}{name}"): + text = (element.text or "").strip() + if text: + return text + return None + + +def _file_id_from_headers(headers: object) -> str | None: + """Достаёт OC-FileId из заголовков ответа без учёта регистра имени. + + requests отдаёт CaseInsensitiveDict, но на это не полагаемся: сервер или + прокси перед ним может прислать имя в любом регистре ("Oc-Fileid"). + """ + if not isinstance(headers, Mapping): + return None + for name, value in headers.items(): + if str(name).lower() == "oc-fileid" and value and str(value).strip(): + return str(value).strip() + return None + + def build_opencloud_url( space_id: str, folder_segments: tuple[str, ...] | list[str], file_name: str ) -> str: @@ -160,16 +403,8 @@ def build_opencloud_url( if not file_name: raise OpenCloudUploadError("Не задано имя файла для загрузки в OpenCloud.") - encoded_folder = "/".join( - quote(segment, safe="") for segment in folder_segments if segment - ) - encoded_file_name = quote(file_name, safe="") - - path = f"/dav/spaces/{space_id}" - if encoded_folder: - path += f"/{encoded_folder}" - path += f"/{encoded_file_name}" - return f"https://{OPENCLOUD_DOMAIN}{path}" + folder_url = build_opencloud_folder_url(space_id, folder_segments) + return f"{folder_url}/{quote(file_name, safe='')}" def ensure_opencloud_folder( @@ -220,11 +455,14 @@ def upload_local_file_to_opencloud( file_name: str, username: str, token: str, -) -> str: +) -> OpenCloudUploadResult: """Загружает локальный файл в OpenCloud через WebDAV PUT (просто attach). - Возвращает URL, по которому файл загружен (для отображения пользователю / - записи в протокол). + Возвращает WebDAV-адрес, по которому файл загружен (для протокола), и + ссылку на файл в веб-интерфейсе OpenCloud (для пользователя). Ссылка + собирается из заголовка OC-FileId ответа на PUT, а если его нет — из + одного PROPFIND (resolve_opencloud_web_link). Если ссылку получить не + удалось, загрузка не падает: web_url будет None. АВТОРИЗАЦИЯ (подтверждено на проде 16.09, см. debug_access_token.py): используется НЕ Bearer/ID-токен Keycloak, а HTTP Basic Auth — username + @@ -274,4 +512,17 @@ def upload_local_file_to_opencloud( f"HTTP {response.status_code}, url='{url}', " f"body={response.text[:500]!r}" ) - return url + + file_id = _file_id_from_headers(getattr(response, "headers", None)) + if file_id: + return OpenCloudUploadResult( + dav_url=url, web_url=build_opencloud_private_link(file_id) + ) + logger.info( + "В ответе на PUT нет заголовка OC-FileId, запрашиваю ссылку через " + "PROPFIND: url='%s'", + url, + ) + return OpenCloudUploadResult( + dav_url=url, web_url=resolve_opencloud_web_link(url, username, token) + ) diff --git a/app/pipeline/service.py b/app/pipeline/service.py index 6e8e8e2..ecf1b2b 100644 --- a/app/pipeline/service.py +++ b/app/pipeline/service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import tempfile import time from collections.abc import Callable, Sequence @@ -9,9 +10,14 @@ from pathlib import Path from ..opencloud_access import ( OpenCloudUploadError, + OpenCloudUploadResult, + PersonalDrive, + build_opencloud_folder_url, + build_opencloud_web_folder_url, ensure_opencloud_folder, next_opencloud_run_number, - resolve_personal_drive_id, + resolve_opencloud_file_id, + resolve_personal_drive, upload_local_file_to_opencloud, ) from .attachments import collect_xml_attachments @@ -28,6 +34,8 @@ from .report import ReportRow, StreamingReportWriter EMPTY_BATCH_STATUS = "info" +logger = logging.getLogger(__name__) + # Вызывается после каждого обработанного XML-файла: (номер файла, всего # файлов, имя файла). Нужен, чтобы вызывающий код (main.py) мог показывать # прогресс длинной обработки вместо одного блокирующего спиннера — см. @@ -42,11 +50,20 @@ class BatchResult: processed: int partial: int errors: int + # WebDAV-адреса загруженных файлов (/dav/spaces/...) — для протокола и + # отладки, пользователю их не показываем. report_path: str protocol_path: str log_path: str messages: list[str] = field(default_factory=list) duration_seconds: float = 0.0 + # Ссылки для пользователя: открывают файл (папку запуска) в веб-интерфейсе + # OpenCloud. Если ссылку на файл получить не удалось — ссылка на папку + # запуска (см. _pick_link); она собирается по пути и есть всегда. + report_url: str = "" + protocol_url: str = "" + log_url: str = "" + folder_url: str = "" # Заполняются, только если файлы обработаны, но загрузка в OpenCloud не # удалась: раньше в этом случае отчёт удалялся вместе с временной # папкой, и 40 минут обработки большого батча пропадали впустую. @@ -128,7 +145,7 @@ def run_batch( # обработки, когда дело дошло до загрузки. Неверный/отозванный App Token # или несовпадение username должны всплывать сразу. try: - opencloud_space_id = resolve_personal_drive_id( + personal_drive = resolve_personal_drive( opencloud_username, opencloud_access_token ) except OpenCloudUploadError as exc: @@ -261,8 +278,10 @@ def run_batch( # Замер на каждый файл — чтобы по логу было видно, растёт ли # время на файл по ходу батча (накопление) или его съедает # конкретный тяжёлый файл (много операций/участников). - # 100 файлов = 6 мин, 230 файлов = 45 мин — нелинейно, причина - # пока не локализована. + # Наблюдавшиеся 100 файлов = 6 мин и 230 файлов = 45 мин + # объясняются не накоплением: время линейно по числу строк + # отчёта (у батчей было разное число операций на файл), а под + # работал на 0.25 CPU — профиль «Малый» в raisa_config.yml. log_lines.append( f"{_now()} | parsed {file_name}: {status} | " f"{file_number}/{total_files} | " @@ -322,10 +341,10 @@ def run_batch( try: # Номер запуска для папки дня в OpenCloud определяем по # самому OpenCloud — см. next_opencloud_run_number. - # opencloud_space_id получен в начале run_batch (проверка + # personal_drive получен в начале run_batch (проверка # доступа до обработки). run_number = next_opencloud_run_number( - opencloud_space_id, + personal_drive.id, (DEFAULT_OUTPUT_DIR_NAME, year, month, day), username=opencloud_username, token=opencloud_access_token, @@ -333,14 +352,14 @@ def run_batch( report_file_name = build_report_file_name(date_key, run_number) protocol_file_name = build_protocol_file_name(date_key, run_number) log_file_name = build_process_log_file_name(date_key, run_number) - report_path, protocol_path, log_path = _upload_artifacts( + uploaded = _upload_artifacts( local_report=local_report, local_protocol=local_protocol, local_log=local_log, report_file_name=report_file_name, protocol_file_name=protocol_file_name, log_file_name=log_file_name, - space_id=opencloud_space_id, + personal_drive=personal_drive, opencloud_username=opencloud_username, opencloud_access_token=opencloud_access_token, year=year, @@ -393,6 +412,11 @@ def run_batch( messages.append(f"Пропущено повторно прикреплённых файлов: {skipped}.") if attachment_failures: messages.append("Не обработаны вложения: " + "; ".join(attachment_failures)) + if uploaded.has_fallback_links: + messages.append( + "Не удалось получить ссылку на файл в веб-интерфейсе OpenCloud — " + "такая кнопка откроет папку с результатами." + ) duration_seconds = round(time.perf_counter() - started_at, 2) messages.append(f"Время выполнения: {_format_duration(duration_seconds)}.") messages.extend(log_lines[-5:]) @@ -401,11 +425,15 @@ def run_batch( processed=processed, partial=partial, errors=errors, - report_path=report_path, - protocol_path=protocol_path, - log_path=log_path, + report_path=uploaded.report.dav_url, + protocol_path=uploaded.protocol.dav_url, + log_path=uploaded.log.dav_url, messages=messages, duration_seconds=duration_seconds, + report_url=uploaded.report_url, + protocol_url=uploaded.protocol_url, + log_url=uploaded.log_url, + folder_url=uploaded.folder_url, ) @@ -428,21 +456,22 @@ def _upload_artifacts( report_file_name: str, protocol_file_name: str, log_file_name: str, - space_id: str, + personal_drive: PersonalDrive, opencloud_username: str, opencloud_access_token: str, year: str, month: str, day: str, run_number: int, -) -> tuple[str, str, str]: - # space_id (личное пространство пользователя) резолвится вызывающим +) -> _UploadedArtifacts: + # personal_drive (личное пространство пользователя) резолвится вызывающим # кодом заново перед каждой загрузкой (не кешируем) — см. - # resolve_personal_drive_id. Кладём файлы по структуре + # resolve_personal_drive. Кладём файлы по структуре # Out/год/месяц/день/номер_запуска. # # opencloud_access_token здесь — App Token заказчика (не Bearer/ID-токен), # авторизация идёт через HTTP Basic Auth вместе с opencloud_username. + space_id = personal_drive.id folder_segments = (DEFAULT_OUTPUT_DIR_NAME, year, month, day, str(run_number)) ensure_opencloud_folder( space_id, @@ -450,7 +479,7 @@ def _upload_artifacts( username=opencloud_username, token=opencloud_access_token, ) - report_url = upload_local_file_to_opencloud( + report = upload_local_file_to_opencloud( local_report, space_id=space_id, folder_segments=folder_segments, @@ -458,7 +487,7 @@ def _upload_artifacts( username=opencloud_username, token=opencloud_access_token, ) - protocol_url = upload_local_file_to_opencloud( + protocol = upload_local_file_to_opencloud( local_protocol, space_id=space_id, folder_segments=folder_segments, @@ -466,7 +495,7 @@ def _upload_artifacts( username=opencloud_username, token=opencloud_access_token, ) - log_url = upload_local_file_to_opencloud( + log = upload_local_file_to_opencloud( local_log, space_id=space_id, folder_segments=folder_segments, @@ -474,7 +503,69 @@ def _upload_artifacts( username=opencloud_username, token=opencloud_access_token, ) - return report_url, protocol_url, log_url + + # Ссылка на папку запуска собирается по пути и есть всегда; fileId папки + # только уточняет её, как в адресной строке самого интерфейса. Если + # ссылку на файл получить не удалось, кнопка файла ведёт в эту папку — + # обработка из-за ссылок не падает. + folder_file_id = resolve_opencloud_file_id( + build_opencloud_folder_url(space_id, folder_segments), + opencloud_username, + opencloud_access_token, + ) + folder_url = build_opencloud_web_folder_url( + _drive_alias(personal_drive, opencloud_username), + folder_segments, + folder_file_id, + ) + return _UploadedArtifacts( + report=report, + protocol=protocol, + log=log, + report_url=_pick_link("отчёт", report, folder_url), + protocol_url=_pick_link("протокол", protocol, folder_url), + log_url=_pick_link("лог", log, folder_url), + folder_url=folder_url, + has_fallback_links=not all(item.web_url for item in (report, protocol, log)), + ) + + +def _drive_alias(personal_drive: PersonalDrive, username: str) -> str: + if personal_drive.alias: + return personal_drive.alias + # Так выглядит путь к личному пространству в адресной строке интерфейса: + # /files/spaces/personal/<логин>/... + logger.warning( + "Graph API не вернул driveAlias личного пространства, собираю путь " + "из логина: personal/%s", + username, + ) + return f"personal/{username}" + + +@dataclass(frozen=True) +class _UploadedArtifacts: + report: OpenCloudUploadResult + protocol: OpenCloudUploadResult + log: OpenCloudUploadResult + report_url: str + protocol_url: str + log_url: str + folder_url: str + # Хотя бы для одного файла вместо ссылки на него отдана запасная. + has_fallback_links: bool + + +def _pick_link(label: str, uploaded: OpenCloudUploadResult, folder_url: str) -> str: + if uploaded.web_url: + return uploaded.web_url + logger.warning( + "Нет ссылки на %s (%s) в веб-интерфейсе OpenCloud, показываю " + "ссылку на папку запуска", + label, + uploaded.dav_url, + ) + return folder_url def _describe_opencloud_access_error(exc: Exception, username: str) -> str: diff --git a/main.py b/main.py index be2ec27..8381918 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,8 @@ import asyncio +import logging import shutil import tempfile +from collections.abc import MutableMapping, Sequence from pathlib import Path import streamlit as st @@ -12,12 +14,33 @@ from app.app_config import ( resolve_user_id, ) from app.auth.service import AuthService, clear_session +from app.opencloud_access import DriveQuota, resolve_personal_drive from app.opencloud_token_store import OpenCloudTokenStoreError, add_token -from app.pipeline.service import run_batch +from app.pipeline.service import BatchResult, run_batch + +logger = logging.getLogger(__name__) + +# Ключ загрузчика зависит от счётчика: Streamlit не умеет очищать +# file_uploader программно, но виджет с новым ключом создаётся пустым. +UPLOADER_GENERATION_KEY = "uploader_generation" +# file_id вложений, которые пользователь исключил из обработки. Убрать файл +# из самого file_uploader нельзя, поэтому он остаётся в загрузчике, но не +# передаётся в run_batch. +EXCLUDED_FILE_IDS_KEY = "excluded_file_ids" +# Квота личного пространства (DriveQuota или None, если узнать не удалось). +# Запрашивается один раз за сессию — см. _render_quota. +QUOTA_STATE_KEY = "opencloud_quota" +# Со скольких файлов список вложений получает свою прокрутку, чтобы партия +# из ~100 файлов не растягивала страницу. +_ATTACHMENT_LIST_SCROLL_FROM = 10 +_BYTES_IN_GB = 1024**3 def main() -> None: load_dotenv() + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" + ) st.set_page_config(page_title="СФМ", page_icon=":lock:", layout="wide") st.title("СФМ") @@ -25,9 +48,12 @@ def main() -> None: if not AuthService().run(): return + _init_attachment_state(st.session_state) + _, center_col, _ = st.columns([1, 2, 1]) with center_col: _render_token_popover() + _render_quota() st.caption( "Прикрепите XML-файлы и/или архивы с ними (можно несколько сразу, " @@ -39,16 +65,18 @@ def main() -> None: "XML-файлы и архивы", type=["xml", "zip", "tar", "gz", "tgz", "bz2", "xz"], accept_multiple_files=True, + key=f"uploader_{st.session_state[UPLOADER_GENERATION_KEY]}", ) + active_files = _render_attachment_list(uploaded_files or []) if st.button( "Запустить обработку xml в excel", type="primary", use_container_width=True, - disabled=not uploaded_files, + disabled=not active_files, ): with tempfile.TemporaryDirectory() as tmp_dir: - input_paths = _save_attachments(uploaded_files, Path(tmp_dir)) + input_paths = _save_attachments(active_files, Path(tmp_dir)) progress_bar = st.progress(0.0, text="Подготовка файлов...") status_placeholder = st.empty() @@ -81,16 +109,7 @@ def main() -> None: st.warning("Обработка завершена частично.") else: st.error("Обработка завершена с ошибками.") - st.write( - { - "processed": batch_result.processed, - "partial": batch_result.partial, - "errors": batch_result.errors, - "report_path": batch_result.report_path, - "protocol_path": batch_result.protocol_path, - "log_path": batch_result.log_path, - } - ) + _render_result_links(batch_result) if batch_result.messages: st.caption("Последние сообщения обработки:") for message in batch_result.messages: @@ -111,6 +130,185 @@ def main() -> None: st.rerun() +def _init_attachment_state(state: MutableMapping) -> None: + if UPLOADER_GENERATION_KEY not in state: + state[UPLOADER_GENERATION_KEY] = 0 + if EXCLUDED_FILE_IDS_KEY not in state: + state[EXCLUDED_FILE_IDS_KEY] = set() + + +def _clear_all_attachments(state: MutableMapping) -> None: + """Сбрасывает загрузчик (новый ключ — пустой виджет) и список исключённых. + + Партии бывают по ~100 файлов, и удалять их по одному крестиком долго. + Автоматически очищать загрузчик после обработки нельзя: к уже + прикреплённым файлам иногда докидывают новые. + """ + state[UPLOADER_GENERATION_KEY] += 1 + state[EXCLUDED_FILE_IDS_KEY] = set() + + +def _exclude_checkbox_key(generation: int, file_id: str) -> str: + return f"exclude_{generation}_{file_id}" + + +def _active_uploads(uploaded_files: Sequence, excluded_file_ids: set[str]) -> list: + """Вложения, которые пойдут в обработку. + + Файл определяется по file_id, а не по имени: имена вложений могут + совпадать (два архива «Файлы.zip» из разных папок). + """ + return [ + uploaded + for uploaded in uploaded_files + if uploaded.file_id not in excluded_file_ids + ] + + +def _exclude_selected(state: MutableMapping, active_files: Sequence) -> int: + """Переносит отмеченные чекбоксами вложения в исключённые. + + Возвращает, сколько файлов исключено. + """ + generation = state[UPLOADER_GENERATION_KEY] + selected = { + uploaded.file_id + for uploaded in active_files + if state.get(_exclude_checkbox_key(generation, uploaded.file_id)) + } + if selected: + state[EXCLUDED_FILE_IDS_KEY] = set(state[EXCLUDED_FILE_IDS_KEY]) | selected + return len(selected) + + +def _render_attachment_list(uploaded_files: Sequence) -> list: + """Список прикреплённых файлов с групповым исключением и очисткой. + + Возвращает вложения, которые нужно передать в обработку. + """ + if not uploaded_files: + return [] + + state = st.session_state + generation = state[UPLOADER_GENERATION_KEY] + active_files = _active_uploads(uploaded_files, state[EXCLUDED_FILE_IDS_KEY]) + excluded_count = len(uploaded_files) - len(active_files) + + st.caption( + f"В обработку пойдёт файлов: {len(active_files)} из {len(uploaded_files)}." + ) + if active_files: + scroll_height = ( + 300 if len(active_files) >= _ATTACHMENT_LIST_SCROLL_FROM else None + ) + with st.container(height=scroll_height, border=True): + for uploaded in active_files: + st.checkbox( + f"{uploaded.name} ({_format_file_size(uploaded.size)})", + key=_exclude_checkbox_key(generation, uploaded.file_id), + ) + + exclude_col, clear_col = st.columns(2) + with exclude_col: + if st.button( + "Исключить выбранные", + use_container_width=True, + disabled=not active_files, + ): + if _exclude_selected(state, active_files): + st.rerun() + st.info("Отметьте файлы, которые нужно исключить.") + with clear_col: + if st.button("Очистить все вложения", use_container_width=True): + _clear_all_attachments(state) + st.rerun() + + if excluded_count: + st.caption( + f"Исключено из обработки: {excluded_count}. Они остаются в " + "загрузчике выше, но обрабатываться не будут." + ) + if st.button("Вернуть исключённые", key="restore_excluded"): + state[EXCLUDED_FILE_IDS_KEY] = set() + st.rerun() + return active_files + + +def _format_file_size(size_bytes: int) -> str: + if size_bytes < 1024: + return f"{size_bytes} Б" + if size_bytes < 1024**2: + return f"{size_bytes / 1024:.0f} КБ" + return f"{size_bytes / 1024**2:.1f} МБ".replace(".", ",") + + +def _render_quota() -> None: + """«Занято X из Y ГБ» в личном пространстве OpenCloud. + + Квота запрашивается один раз за сессию (кеш в session_state, кнопка + «Обновить» сбрасывает его). Если узнать её не удалось — ничего не + показываем: это справочная информация, страница из-за неё падать не + должна. + """ + state = st.session_state + if QUOTA_STATE_KEY not in state: + state[QUOTA_STATE_KEY] = _load_quota() + text = _format_quota(state[QUOTA_STATE_KEY]) + if not text: + return + text_col, refresh_col = st.columns([4, 1], vertical_alignment="center") + text_col.caption(text) + if refresh_col.button("Обновить", key="opencloud_quota_refresh"): + state.pop(QUOTA_STATE_KEY, None) + st.rerun() + + +def _load_quota() -> DriveQuota | None: + try: + username = resolve_opencloud_username() + token = resolve_opencloud_access_token() if username else None + if not username or not token: + return None + return resolve_personal_drive(username, token).quota + except Exception: # noqa: BLE001 + logger.warning("Не удалось получить квоту OpenCloud", exc_info=True) + return None + + +def _format_quota(quota: DriveQuota | None) -> str | None: + if quota is None: + return None + used = _format_gigabytes(quota.used) + if quota.total: + return f"Занято {used} из {_format_gigabytes(quota.total)} ГБ" + return f"Занято {used} ГБ" + + +def _format_gigabytes(size_bytes: int) -> str: + value = size_bytes / _BYTES_IN_GB + text = f"{value:.2f}" if value < 1 else f"{value:.1f}" + return text.rstrip("0").rstrip(".").replace(".", ",") + + +def _result_links(batch_result: BatchResult) -> list[tuple[str, str]]: + """Кнопки-ссылки на результат: только те, для которых есть адрес.""" + links = [ + ("Открыть отчёт", batch_result.report_url), + ("Протокол", batch_result.protocol_url), + ("Лог", batch_result.log_url), + ("Папка с результатами", batch_result.folder_url), + ] + return [(label, url) for label, url in links if url] + + +def _render_result_links(batch_result: BatchResult) -> None: + links = _result_links(batch_result) + if not links: + return + for column, (label, url) in zip(st.columns(len(links)), links, strict=True): + column.link_button(label, url, use_container_width=True) + + def _save_attachments(uploaded_files, target_dir: Path) -> list[Path]: """Складывает вложения на диск, каждое в свой подкаталог. @@ -160,6 +358,9 @@ def _render_token_popover() -> None: st.error(f"Не удалось сохранить токен: {exc}") else: st.success("Токен сохранён.") + # С новым токеном квоту можно узнать заново (без токена + # в кеше лежит None). + st.session_state.pop(QUOTA_STATE_KEY, None) if __name__ == "__main__": diff --git a/tests/smoke/test_batch_links.py b/tests/smoke/test_batch_links.py new file mode 100644 index 0000000..ab76cf0 --- /dev/null +++ b/tests/smoke/test_batch_links.py @@ -0,0 +1,162 @@ +"""Ссылки на результат обработки: на файл -> на папку запуска. + +Ссылка на папку запуска собирается по пути в формате адресной строки +веб-интерфейса OpenCloud и есть всегда, поэтому без ссылки пользователь не +останется, а обработка из-за ссылок не падает. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import Mock + +import requests + +from app import opencloud_access +from app.opencloud_access import OpenCloudUploadResult +from app.pipeline import service +from app.pipeline.config import build_date_parts +from app.pipeline.service import BatchResult, run_batch +from main import _result_links +from tests.smoke.test_batch_smoke import _patch_opencloud, _valid_xml + +WEB_SPACE = "https://docs.raisa.go.rshbank.ru/files/spaces/personal" + + +def _loose_xml(tmp_path: Path) -> Path: + path = tmp_path / "SKO115FZ_01_123456789_20260616_X00001.xml" + path.write_text(_valid_xml(), encoding="utf-8") + return path + + +def _run(tmp_path: Path): + return run_batch( + input_paths=[_loose_xml(tmp_path)], + launcher="tester", + opencloud_username="test-user", + opencloud_access_token="token-123", + ) + + +def _run_folder_path() -> str: + year, month, day, _ = build_date_parts() + return f"Out/{year}/{month}/{day}/1" + + +def test_links_open_files_and_run_folder_in_web_ui(tmp_path: Path, monkeypatch) -> None: + _patch_opencloud(monkeypatch) + folder_requests: list[str] = [] + + def _folder_file_id(dav_url, username, token): + folder_requests.append(dav_url) + return "store$space!folder" + + monkeypatch.setattr(service, "resolve_opencloud_file_id", _folder_file_id) + + result = _run(tmp_path) + + assert result.status == "success" + assert result.report_url.startswith("https://opencloud.test/f/") + assert result.report_url.endswith(".xlsx") + assert result.protocol_url.startswith("https://opencloud.test/f/") + assert result.log_url.endswith(".log") + # Формат — как в адресной строке интерфейса: путь + fileId папки. + assert result.folder_url == ( + f"{WEB_SPACE}/test-user/{_run_folder_path()}" "?fileId=store%24space%21folder" + ) + # fileId запрашивается именно для папки запуска Out/год/месяц/день/номер. + assert folder_requests == [ + f"https://docs.raisa.go.rshbank.ru/dav/spaces/personal-space-1/" + f"{_run_folder_path()}" + ] + # WebDAV-адреса остаются для протокола и отладки. + assert "/Out/" in result.report_path + assert not any("веб-интерфейсе" in message for message in result.messages) + + +def test_file_without_link_falls_back_to_run_folder( + tmp_path: Path, monkeypatch +) -> None: + _patch_opencloud(monkeypatch) + + def _upload_without_link( + local_path, space_id, folder_segments, file_name, username, token + ): + return OpenCloudUploadResult(dav_url=f"https://dav.test/{file_name}") + + monkeypatch.setattr(service, "upload_local_file_to_opencloud", _upload_without_link) + + result = _run(tmp_path) + + assert result.status == "success" + assert result.folder_url.startswith(f"{WEB_SPACE}/test-user/Out/") + assert result.report_url == result.folder_url + assert result.protocol_url == result.folder_url + assert result.log_url == result.folder_url + assert any("веб-интерфейсе" in message for message in result.messages) + + +def test_nothing_resolved_still_gives_run_folder_link( + tmp_path: Path, monkeypatch +) -> None: + """Сквозной сценарий на уровне requests: в ответе на PUT нет OC-FileId, + PROPFIND за ссылками падает, Graph API не вернул driveAlias — обработка + успешна, все кнопки ведут в папку запуска (путь из логина, без fileId).""" + calls: list[tuple[str, str]] = [] + + def _get(url, **kwargs): + return Mock( + status_code=200, + json=lambda: {"value": [{"id": "s$p", "driveType": "personal"}]}, + ) + + def _request(method, url, **kwargs): + depth = kwargs.get("headers", {}).get("Depth") + calls.append((method, depth)) + if method == "PROPFIND" and depth == "1": + return Mock(status_code=404, text="") + if method == "MKCOL": + return Mock(status_code=201, text="") + raise requests.ConnectionError("PROPFIND за ссылкой не прошёл") + + def _put(url, **kwargs): + return Mock(status_code=201, text="", headers={}) + + monkeypatch.setattr(opencloud_access.requests, "get", _get) + monkeypatch.setattr(opencloud_access.requests, "request", _request) + monkeypatch.setattr(opencloud_access.requests, "put", _put) + + result = _run(tmp_path) + + assert result.status == "success" + assert result.folder_url == f"{WEB_SPACE}/test-user/{_run_folder_path()}" + assert result.report_url == result.folder_url + assert result.protocol_url == result.folder_url + assert result.log_url == result.folder_url + assert result.report_path.startswith( + "https://docs.raisa.go.rshbank.ru/dav/spaces/s$p/Out/" + ) + # Три файла без OC-FileId + папка запуска: по одному PROPFIND (Depth: 0). + assert calls.count(("PROPFIND", "0")) == 4 + + +def test_result_link_buttons_skip_missing_addresses() -> None: + result = BatchResult( + status="success", + processed=1, + partial=0, + errors=0, + report_path="dav-report", + protocol_path="dav-protocol", + log_path="dav-log", + report_url="https://x/f/1", + protocol_url="https://x/f/2", + log_url="", + folder_url="https://x/f/3", + ) + + assert _result_links(result) == [ + ("Открыть отчёт", "https://x/f/1"), + ("Протокол", "https://x/f/2"), + ("Папка с результатами", "https://x/f/3"), + ] diff --git a/tests/smoke/test_batch_smoke.py b/tests/smoke/test_batch_smoke.py index f3563af..e6b57ca 100644 --- a/tests/smoke/test_batch_smoke.py +++ b/tests/smoke/test_batch_smoke.py @@ -4,6 +4,7 @@ import tarfile import zipfile from pathlib import Path +from app.opencloud_access import OpenCloudUploadResult, PersonalDrive from app.pipeline import service from app.pipeline.service import run_batch @@ -26,14 +27,22 @@ def _valid_xml() -> str: def _fake_opencloud_upload( local_path: Path, space_id, folder_segments, file_name, username, token -) -> str: +) -> OpenCloudUploadResult: assert local_path.exists() - return f"https://opencloud.test/{'/'.join(folder_segments)}/{file_name}" + return OpenCloudUploadResult( + dav_url=f"https://opencloud.test/{'/'.join(folder_segments)}/{file_name}", + web_url=f"https://opencloud.test/f/{file_name}", + ) -def _fake_resolve_personal_drive_id(username, token) -> str: +def _fake_resolve_opencloud_file_id(dav_url, username, token) -> str: + _ = (dav_url, username, token) + return "store$space!folder" + + +def _fake_resolve_personal_drive(username, token) -> PersonalDrive: _ = (username, token) - return "personal-space-1" + return PersonalDrive(id="personal-space-1", alias="personal/test-user") def _fake_ensure_opencloud_folder(space_id, folder_segments, username, token) -> None: @@ -51,15 +60,16 @@ def _patch_opencloud(monkeypatch) -> None: monkeypatch.setattr( service, "upload_local_file_to_opencloud", _fake_opencloud_upload ) - monkeypatch.setattr( - service, "resolve_personal_drive_id", _fake_resolve_personal_drive_id - ) + monkeypatch.setattr(service, "resolve_personal_drive", _fake_resolve_personal_drive) monkeypatch.setattr( service, "ensure_opencloud_folder", _fake_ensure_opencloud_folder ) monkeypatch.setattr( service, "next_opencloud_run_number", _fake_next_opencloud_run_number ) + monkeypatch.setattr( + service, "resolve_opencloud_file_id", _fake_resolve_opencloud_file_id + ) def test_run_batch_uses_run_number_from_opencloud(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/unit/test_main_attachments.py b/tests/unit/test_main_attachments.py index a05c37e..4b5f2e6 100644 --- a/tests/unit/test_main_attachments.py +++ b/tests/unit/test_main_attachments.py @@ -4,6 +4,9 @@ UploadedFile — это BytesIO: Streamlit уже держит всё содер памяти. getvalue() делает ещё одну полную копию каждого файла перед записью на диск; при 230 вложениях в контейнере на 1 ГБ это лишняя пиковая нагрузка. Копировать нужно потоково, через файловый интерфейс, не трогая getvalue(). + +Исключение вложений из обработки (без удаления из file_uploader) и сброс +загрузчика — по file_id, потому что имена вложений могут совпадать. """ from __future__ import annotations @@ -11,7 +14,16 @@ from __future__ import annotations import io from pathlib import Path -from main import _save_attachments +from main import ( + EXCLUDED_FILE_IDS_KEY, + UPLOADER_GENERATION_KEY, + _active_uploads, + _clear_all_attachments, + _exclude_checkbox_key, + _exclude_selected, + _init_attachment_state, + _save_attachments, +) class _UploadWithoutGetValue(io.BytesIO): @@ -48,3 +60,78 @@ def test_save_attachments_copies_from_start_even_if_upload_was_read( (path,) = _save_attachments([upload], tmp_path) assert path.read_bytes() == b"0123456789" + + +class _Upload: + """Минимальный UploadedFile: имя и file_id, по которому его отличаем.""" + + def __init__(self, name: str, file_id: str, size: int = 10) -> None: + self.name = name + self.file_id = file_id + self.size = size + + +def test_excluded_files_are_matched_by_file_id_not_by_name() -> None: + first = _Upload("Файлы.zip", "id-1") + second = _Upload("Файлы.zip", "id-2") + other = _Upload("a.xml", "id-3") + + active = _active_uploads([first, second, other], {"id-2"}) + + assert active == [first, other] + + +def test_exclude_selected_moves_checked_files_to_excluded() -> None: + state: dict = {} + _init_attachment_state(state) + first = _Upload("Файлы.zip", "id-1") + second = _Upload("Файлы.zip", "id-2") + state[_exclude_checkbox_key(0, "id-2")] = True + state[_exclude_checkbox_key(0, "id-1")] = False + + excluded = _exclude_selected(state, [first, second]) + + assert excluded == 1 + assert state[EXCLUDED_FILE_IDS_KEY] == {"id-2"} + assert _active_uploads([first, second], state[EXCLUDED_FILE_IDS_KEY]) == [first] + + +def test_exclude_selected_keeps_previous_exclusions() -> None: + state: dict = {EXCLUDED_FILE_IDS_KEY: {"id-1"}, UPLOADER_GENERATION_KEY: 2} + state[_exclude_checkbox_key(2, "id-3")] = True + # Отметка из прошлого поколения загрузчика не должна ничего исключать. + state[_exclude_checkbox_key(1, "id-4")] = True + + excluded = _exclude_selected( + state, [_Upload("c.xml", "id-3"), _Upload("d.xml", "id-4")] + ) + + assert excluded == 1 + assert state[EXCLUDED_FILE_IDS_KEY] == {"id-1", "id-3"} + + +def test_exclude_selected_without_checked_files_changes_nothing() -> None: + state: dict = {} + _init_attachment_state(state) + + assert _exclude_selected(state, [_Upload("a.xml", "id-1")]) == 0 + assert state[EXCLUDED_FILE_IDS_KEY] == set() + + +def test_clear_all_resets_uploader_key_and_exclusions() -> None: + state: dict = {} + _init_attachment_state(state) + state[EXCLUDED_FILE_IDS_KEY] = {"id-1", "id-2"} + + _clear_all_attachments(state) + + assert state[UPLOADER_GENERATION_KEY] == 1 + assert state[EXCLUDED_FILE_IDS_KEY] == set() + + +def test_init_attachment_state_keeps_existing_values() -> None: + state: dict = {UPLOADER_GENERATION_KEY: 5, EXCLUDED_FILE_IDS_KEY: {"id-1"}} + + _init_attachment_state(state) + + assert state == {UPLOADER_GENERATION_KEY: 5, EXCLUDED_FILE_IDS_KEY: {"id-1"}} diff --git a/tests/unit/test_opencloud_access.py b/tests/unit/test_opencloud_access.py index ba994a4..fd820af 100644 --- a/tests/unit/test_opencloud_access.py +++ b/tests/unit/test_opencloud_access.py @@ -44,9 +44,9 @@ def test_upload_local_file_to_opencloud_success(tmp_path: Path) -> None: local_file = tmp_path / "report.xlsx" local_file.write_bytes(b"fake-xlsx-bytes") - fake_response = Mock(status_code=201, text="") + fake_response = Mock(status_code=201, text="", headers={"OC-FileId": "s$p!f"}) with patch("app.opencloud_access.requests.put", return_value=fake_response) as put: - url = upload_local_file_to_opencloud( + result = upload_local_file_to_opencloud( local_file, space_id="abc$def", folder_segments=("Райков М.С.",), @@ -55,7 +55,7 @@ def test_upload_local_file_to_opencloud_success(tmp_path: Path) -> None: token="coherent effects plausibly apple arming chef", ) - assert url.endswith("/report.xlsx") + assert result.dav_url.endswith("/report.xlsx") _, kwargs = put.call_args # Basic Auth (App Token), не Bearer — см. app/opencloud_access.py assert "Authorization" not in kwargs["headers"] diff --git a/tests/unit/test_opencloud_links.py b/tests/unit/test_opencloud_links.py new file mode 100644 index 0000000..6404a1e --- /dev/null +++ b/tests/unit/test_opencloud_links.py @@ -0,0 +1,217 @@ +"""Ссылки на загруженные файлы в веб-интерфейсе OpenCloud. + +Цепочка: заголовок OC-FileId ответа на PUT -> PROPFIND (Depth: 0) за +oc:privatelink / oc:fileid -> None (запасную ссылку подставляет service). +Поведение реального сервера не проверено, поэтому ни один шаг не должен +ронять загрузку. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import Mock, patch + +import requests + +from app.opencloud_access import ( + build_opencloud_folder_url, + build_opencloud_private_link, + build_opencloud_web_folder_url, + resolve_opencloud_file_id, + resolve_opencloud_web_link, + resolve_personal_drive, + upload_local_file_to_opencloud, +) + +DAV_URL = "https://docs.raisa.go.rshbank.ru/dav/spaces/abc/Out/report.xlsx" + + +def _propfind_body(privatelink: str = "", fileid: str = "") -> bytes: + found = [] + missing = [] + for name, value in (("privatelink", privatelink), ("fileid", fileid)): + if value: + found.append(f"{value}") + else: + missing.append(f"") + return f""" + + + /dav/spaces/abc/Out/report.xlsx + {''.join(found)} + HTTP/1.1 200 OK + {''.join(missing)} + HTTP/1.1 404 Not Found + +""".encode() + + +def _upload(tmp_path: Path, put_response: Mock): + local_file = tmp_path / "report.xlsx" + local_file.write_bytes(b"xlsx") + with patch("app.opencloud_access.requests.put", return_value=put_response): + return upload_local_file_to_opencloud( + local_file, + space_id="abc", + folder_segments=("Out",), + file_name="report.xlsx", + username="raykovmse", + token="token-123", + ) + + +def test_upload_takes_file_id_from_put_header_case_insensitively( + tmp_path: Path, +) -> None: + put_response = Mock( + status_code=201, text="", headers={"oc-fileid": "store$space!opaque"} + ) + with patch("app.opencloud_access.requests.request") as request: + result = _upload(tmp_path, put_response) + + assert result.dav_url == DAV_URL + assert result.web_url == ( + "https://docs.raisa.go.rshbank.ru/f/store%24space%21opaque" + ) + request.assert_not_called() + + +def test_upload_without_header_uses_privatelink_from_propfind_as_is( + tmp_path: Path, +) -> None: + put_response = Mock(status_code=204, text="", headers={"ETag": '"1"'}) + propfind_response = Mock( + status_code=207, + content=_propfind_body( + privatelink="https://docs.raisa.go.rshbank.ru/f/from-server", + fileid="ignored", + ), + ) + with patch( + "app.opencloud_access.requests.request", return_value=propfind_response + ) as request: + result = _upload(tmp_path, put_response) + + assert result.web_url == "https://docs.raisa.go.rshbank.ru/f/from-server" + request.assert_called_once() + method, url = request.call_args.args + assert method == "PROPFIND" + assert url == DAV_URL + kwargs = request.call_args.kwargs + assert kwargs["headers"]["Depth"] == "0" + body = kwargs["data"].decode("utf-8") + assert 'xmlns:oc="http://owncloud.org/ns"' in body + assert "" in body + assert "" in body + assert kwargs["auth"].username == "raykovmse" + + +def test_propfind_without_privatelink_builds_link_from_fileid() -> None: + propfind_response = Mock(status_code=207, content=_propfind_body(fileid="s$p!f")) + with patch("app.opencloud_access.requests.request", return_value=propfind_response): + link = resolve_opencloud_web_link(DAV_URL, "raykovmse", "token-123") + + assert link == build_opencloud_private_link("s$p!f") + + +def test_upload_survives_when_no_link_can_be_obtained(tmp_path: Path) -> None: + put_response = Mock(status_code=201, text="", headers={}) + with patch( + "app.opencloud_access.requests.request", + side_effect=requests.ConnectionError("down"), + ): + result = _upload(tmp_path, put_response) + + assert result.dav_url == DAV_URL + assert result.web_url is None + + +def test_propfind_http_error_gives_no_link() -> None: + with patch( + "app.opencloud_access.requests.request", + return_value=Mock(status_code=403, content=b""), + ): + assert resolve_opencloud_web_link(DAV_URL, "u", "t") is None + + +def test_propfind_broken_xml_gives_no_link() -> None: + with patch( + "app.opencloud_access.requests.request", + return_value=Mock(status_code=207, content=b" None: + with patch( + "app.opencloud_access.requests.request", + return_value=Mock(status_code=207, content=_propfind_body()), + ): + assert resolve_opencloud_web_link(DAV_URL, "u", "t") is None + + +def test_build_opencloud_folder_url_encodes_segments() -> None: + assert build_opencloud_folder_url("abc$def", ("Out", "2026", "Райков")) == ( + "https://docs.raisa.go.rshbank.ru/dav/spaces/abc$def/Out/2026/" + "%D0%A0%D0%B0%D0%B9%D0%BA%D0%BE%D0%B2" + ) + + +def test_web_folder_url_matches_browser_address_bar() -> None: + """Эталон — адрес папки запуска из браузера на нашем OpenCloud (29.09), + без параметров отображения (items-per-page, view-mode, tiles-size).""" + file_id = ( + "28c0cecf-1e20-4664-b165-5c9d5515e70f$1faf47bc-6134-4130-9f38-d4743818ac63" + "!0dfd7588-9b95-4a74-b477-e25be91eaf49" + ) + + url = build_opencloud_web_folder_url( + "personal/raykov-mse", ("Out", "2026", "9", "29", "1"), file_id + ) + + assert url == ( + "https://docs.raisa.go.rshbank.ru/files/spaces/personal/raykov-mse/" + "Out/2026/9/29/1?fileId=28c0cecf-1e20-4664-b165-5c9d5515e70f" + "%241faf47bc-6134-4130-9f38-d4743818ac63" + "%210dfd7588-9b95-4a74-b477-e25be91eaf49" + ) + + +def test_web_folder_url_without_file_id_is_plain_path() -> None: + assert build_opencloud_web_folder_url("personal/raykov-mse", ("Out", "Райков")) == ( + "https://docs.raisa.go.rshbank.ru/files/spaces/personal/raykov-mse/" + "Out/%D0%A0%D0%B0%D0%B9%D0%BA%D0%BE%D0%B2" + ) + + +def test_resolve_file_id_reads_oc_fileid() -> None: + with patch( + "app.opencloud_access.requests.request", + return_value=Mock(status_code=207, content=_propfind_body(fileid="s$p!d")), + ): + assert resolve_opencloud_file_id(DAV_URL, "u", "t") == "s$p!d" + + +def test_resolve_file_id_survives_network_error() -> None: + with patch( + "app.opencloud_access.requests.request", + side_effect=requests.ConnectionError("down"), + ): + assert resolve_opencloud_file_id(DAV_URL, "u", "t") is None + + +def test_personal_drive_carries_drive_alias() -> None: + response = Mock( + status_code=200, + json=lambda: { + "value": [ + { + "id": "s$p", + "driveType": "personal", + "driveAlias": "personal/raykov-mse", + } + ] + }, + ) + with patch("app.opencloud_access.requests.get", return_value=response): + assert resolve_personal_drive("raykov-mse", "t").alias == "personal/raykov-mse" diff --git a/tests/unit/test_opencloud_quota.py b/tests/unit/test_opencloud_quota.py new file mode 100644 index 0000000..ef1f6bd --- /dev/null +++ b/tests/unit/test_opencloud_quota.py @@ -0,0 +1,129 @@ +"""Квота личного пространства OpenCloud (поле quota в /graph/v1.0/me/drives). + +Наличие поля на нашем сервере не проверено: без него (или при сбое запроса) +интерфейс просто ничего не показывает. +""" + +from __future__ import annotations + +from unittest.mock import Mock, patch + +import requests + +import main +from app.opencloud_access import ( + DriveQuota, + OpenCloudUploadError, + resolve_personal_drive, + resolve_personal_drive_id, +) + + +def _drives_response(personal: dict) -> Mock: + return Mock( + status_code=200, + json=lambda: { + "value": [ + {"id": "s$shared", "driveType": "project", "quota": {"used": 1}}, + {"id": "s$personal", "driveType": "personal", **personal}, + ] + }, + ) + + +def test_quota_is_returned_with_drive_id() -> None: + response = _drives_response( + { + "quota": { + "used": 3 * 1024**3, + "total": 10 * 1024**3, + "remaining": 7 * 1024**3, + "state": "normal", + } + } + ) + with patch("app.opencloud_access.requests.get", return_value=response): + drive = resolve_personal_drive("raykovmse", "token") + + assert drive.id == "s$personal" + assert drive.quota == DriveQuota( + used=3 * 1024**3, total=10 * 1024**3, remaining=7 * 1024**3 + ) + + +def test_drive_id_call_keeps_returning_plain_string() -> None: + response = _drives_response({"quota": {"used": 1, "total": 2}}) + with patch("app.opencloud_access.requests.get", return_value=response): + assert resolve_personal_drive_id("raykovmse", "token") == "s$personal" + + +def test_missing_quota_field_gives_none() -> None: + with patch("app.opencloud_access.requests.get", return_value=_drives_response({})): + drive = resolve_personal_drive("raykovmse", "token") + + assert drive.id == "s$personal" + assert drive.quota is None + + +def test_quota_without_used_gives_none() -> None: + response = _drives_response({"quota": {"total": 100, "state": "normal"}}) + with patch("app.opencloud_access.requests.get", return_value=response): + assert resolve_personal_drive("raykovmse", "token").quota is None + + +def test_zero_total_means_no_limit() -> None: + response = _drives_response({"quota": {"used": 5, "total": 0}}) + with patch("app.opencloud_access.requests.get", return_value=response): + assert resolve_personal_drive("raykovmse", "token").quota == DriveQuota( + used=5, total=None, remaining=None + ) + + +def test_format_quota() -> None: + assert ( + main._format_quota(DriveQuota(used=int(1.25 * 1024**3), total=10 * 1024**3)) + == "Занято 1,2 из 10 ГБ" + ) + assert ( + main._format_quota(DriveQuota(used=512 * 1024**2, total=2 * 1024**3)) + == "Занято 0,5 из 2 ГБ" + ) + assert main._format_quota(DriveQuota(used=0, total=None)) == "Занято 0 ГБ" + assert main._format_quota(None) is None + + +def test_load_quota_returns_quota(monkeypatch) -> None: + monkeypatch.setattr(main, "resolve_opencloud_username", lambda: "raykovmse") + monkeypatch.setattr(main, "resolve_opencloud_access_token", lambda: "token") + response = _drives_response({"quota": {"used": 1, "total": 2}}) + with patch("app.opencloud_access.requests.get", return_value=response): + assert main._load_quota() == DriveQuota(used=1, total=2) + + +def test_load_quota_swallows_failed_request(monkeypatch) -> None: + monkeypatch.setattr(main, "resolve_opencloud_username", lambda: "raykovmse") + monkeypatch.setattr(main, "resolve_opencloud_access_token", lambda: "token") + with patch( + "app.opencloud_access.requests.get", + side_effect=requests.ConnectionError("down"), + ): + assert main._load_quota() is None + + +def test_load_quota_swallows_unexpected_errors(monkeypatch) -> None: + monkeypatch.setattr(main, "resolve_opencloud_username", lambda: "raykovmse") + monkeypatch.setattr(main, "resolve_opencloud_access_token", lambda: "token") + + def _boom(username, token): + raise OpenCloudUploadError("HTTP 500") + + monkeypatch.setattr(main, "resolve_personal_drive", _boom) + assert main._load_quota() is None + + +def test_load_quota_without_token_does_not_call_opencloud(monkeypatch) -> None: + monkeypatch.setattr(main, "resolve_opencloud_username", lambda: "raykovmse") + monkeypatch.setattr(main, "resolve_opencloud_access_token", lambda: None) + with patch("app.opencloud_access.requests.get") as get: + assert main._load_quota() is None + get.assert_not_called()