fix
This commit is contained in:
parent
e74cd69140
commit
ef0f955585
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,6 +8,7 @@ __pycache__/
|
||||
.ruff_cache/
|
||||
|
||||
.env
|
||||
.env.example.superpowers/
|
||||
.env.example
|
||||
.superpowers/
|
||||
.bounded
|
||||
.DS_Store
|
||||
|
||||
@ -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 = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
f'<d:propfind xmlns:d="DAV:" xmlns:oc="{_OC_NAMESPACE}">'
|
||||
"<d:prop><oc:privatelink/><oc:fileid/></d:prop>"
|
||||
"</d:propfind>"
|
||||
)
|
||||
|
||||
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/<fileid>,
|
||||
которая открывает файл в веб-интерфейсе 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/<fileid> на файл или папку.
|
||||
|
||||
Такую же ссылку 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)
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
227
main.py
227
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__":
|
||||
|
||||
162
tests/smoke/test_batch_links.py
Normal file
162
tests/smoke/test_batch_links.py
Normal file
@ -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"),
|
||||
]
|
||||
@ -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:
|
||||
|
||||
@ -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"}}
|
||||
|
||||
@ -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"]
|
||||
|
||||
217
tests/unit/test_opencloud_links.py
Normal file
217
tests/unit/test_opencloud_links.py
Normal file
@ -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"<oc:{name}>{value}</oc:{name}>")
|
||||
else:
|
||||
missing.append(f"<oc:{name}/>")
|
||||
return f"""<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:response>
|
||||
<d:href>/dav/spaces/abc/Out/report.xlsx</d:href>
|
||||
<d:propstat><d:prop>{''.join(found)}</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
||||
<d:propstat><d:prop>{''.join(missing)}</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>""".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 "<oc:privatelink/>" in body
|
||||
assert "<oc:fileid/>" 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"<not xml"),
|
||||
):
|
||||
assert resolve_opencloud_web_link(DAV_URL, "u", "t") is None
|
||||
|
||||
|
||||
def test_propfind_with_empty_properties_gives_no_link() -> 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"
|
||||
129
tests/unit/test_opencloud_quota.py
Normal file
129
tests/unit/test_opencloud_quota.py
Normal file
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user