This commit is contained in:
Raykov-MS 2026-10-16 11:19:18 +03:00
parent e74cd69140
commit ef0f955585
10 changed files with 1210 additions and 61 deletions

3
.gitignore vendored
View File

@ -8,6 +8,7 @@ __pycache__/
.ruff_cache/ .ruff_cache/
.env .env
.env.example.superpowers/ .env.example
.superpowers/
.bounded .bounded
.DS_Store .DS_Store

View File

@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import logging
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from urllib.parse import quote, unquote, urlparse from urllib.parse import quote, unquote, urlparse
@ -20,13 +23,64 @@ _CONTENT_TYPES = {
} }
_DEFAULT_CONTENT_TYPE = "application/octet-stream" _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): class OpenCloudUploadError(RuntimeError):
"""Ошибка загрузки файла в OpenCloud по WebDAV.""" """Ошибка загрузки файла в 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: 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 == SPACE_ID — у каждого заказчика есть личное пространство (driveType ==
@ -74,12 +128,49 @@ def resolve_personal_drive_id(username: str, token: str) -> str:
f"пользователя '{username}' в OpenCloud." 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: if not drive_id:
raise OpenCloudUploadError( raise OpenCloudUploadError(
"Личное пространство найдено в OpenCloud, но у него отсутствует id." "Личное пространство найдено в 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( def next_opencloud_run_number(
@ -142,6 +233,158 @@ def next_opencloud_run_number(
return max(numbers, default=0) + 1 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( def build_opencloud_url(
space_id: str, folder_segments: tuple[str, ...] | list[str], file_name: str space_id: str, folder_segments: tuple[str, ...] | list[str], file_name: str
) -> str: ) -> str:
@ -160,16 +403,8 @@ def build_opencloud_url(
if not file_name: if not file_name:
raise OpenCloudUploadError("Не задано имя файла для загрузки в OpenCloud.") raise OpenCloudUploadError("Не задано имя файла для загрузки в OpenCloud.")
encoded_folder = "/".join( folder_url = build_opencloud_folder_url(space_id, folder_segments)
quote(segment, safe="") for segment in folder_segments if segment return f"{folder_url}/{quote(file_name, safe='')}"
)
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}"
def ensure_opencloud_folder( def ensure_opencloud_folder(
@ -220,11 +455,14 @@ def upload_local_file_to_opencloud(
file_name: str, file_name: str,
username: str, username: str,
token: str, token: str,
) -> str: ) -> OpenCloudUploadResult:
"""Загружает локальный файл в OpenCloud через WebDAV PUT (просто attach). """Загружает локальный файл в OpenCloud через WebDAV PUT (просто attach).
Возвращает URL, по которому файл загружен (для отображения пользователю / Возвращает WebDAV-адрес, по которому файл загружен (для протокола), и
записи в протокол). ссылку на файл в веб-интерфейсе OpenCloud (для пользователя). Ссылка
собирается из заголовка OC-FileId ответа на PUT, а если его нет — из
одного PROPFIND (resolve_opencloud_web_link). Если ссылку получить не
удалось, загрузка не падает: web_url будет None.
АВТОРИЗАЦИЯ (подтверждено на проде 16.09, см. debug_access_token.py): АВТОРИЗАЦИЯ (подтверждено на проде 16.09, см. debug_access_token.py):
используется НЕ Bearer/ID-токен Keycloak, а HTTP Basic Auth — username + используется НЕ 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"HTTP {response.status_code}, url='{url}', "
f"body={response.text[:500]!r}" 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)
)

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import tempfile import tempfile
import time import time
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
@ -9,9 +10,14 @@ from pathlib import Path
from ..opencloud_access import ( from ..opencloud_access import (
OpenCloudUploadError, OpenCloudUploadError,
OpenCloudUploadResult,
PersonalDrive,
build_opencloud_folder_url,
build_opencloud_web_folder_url,
ensure_opencloud_folder, ensure_opencloud_folder,
next_opencloud_run_number, next_opencloud_run_number,
resolve_personal_drive_id, resolve_opencloud_file_id,
resolve_personal_drive,
upload_local_file_to_opencloud, upload_local_file_to_opencloud,
) )
from .attachments import collect_xml_attachments from .attachments import collect_xml_attachments
@ -28,6 +34,8 @@ from .report import ReportRow, StreamingReportWriter
EMPTY_BATCH_STATUS = "info" EMPTY_BATCH_STATUS = "info"
logger = logging.getLogger(__name__)
# Вызывается после каждого обработанного XML-файла: (номер файла, всего # Вызывается после каждого обработанного XML-файла: (номер файла, всего
# файлов, имя файла). Нужен, чтобы вызывающий код (main.py) мог показывать # файлов, имя файла). Нужен, чтобы вызывающий код (main.py) мог показывать
# прогресс длинной обработки вместо одного блокирующего спиннера — см. # прогресс длинной обработки вместо одного блокирующего спиннера — см.
@ -42,11 +50,20 @@ class BatchResult:
processed: int processed: int
partial: int partial: int
errors: int errors: int
# WebDAV-адреса загруженных файлов (/dav/spaces/...) — для протокола и
# отладки, пользователю их не показываем.
report_path: str report_path: str
protocol_path: str protocol_path: str
log_path: str log_path: str
messages: list[str] = field(default_factory=list) messages: list[str] = field(default_factory=list)
duration_seconds: float = 0.0 duration_seconds: float = 0.0
# Ссылки для пользователя: открывают файл (папку запуска) в веб-интерфейсе
# OpenCloud. Если ссылку на файл получить не удалось — ссылка на папку
# запуска (см. _pick_link); она собирается по пути и есть всегда.
report_url: str = ""
protocol_url: str = ""
log_url: str = ""
folder_url: str = ""
# Заполняются, только если файлы обработаны, но загрузка в OpenCloud не # Заполняются, только если файлы обработаны, но загрузка в OpenCloud не
# удалась: раньше в этом случае отчёт удалялся вместе с временной # удалась: раньше в этом случае отчёт удалялся вместе с временной
# папкой, и 40 минут обработки большого батча пропадали впустую. # папкой, и 40 минут обработки большого батча пропадали впустую.
@ -128,7 +145,7 @@ def run_batch(
# обработки, когда дело дошло до загрузки. Неверный/отозванный App Token # обработки, когда дело дошло до загрузки. Неверный/отозванный App Token
# или несовпадение username должны всплывать сразу. # или несовпадение username должны всплывать сразу.
try: try:
opencloud_space_id = resolve_personal_drive_id( personal_drive = resolve_personal_drive(
opencloud_username, opencloud_access_token opencloud_username, opencloud_access_token
) )
except OpenCloudUploadError as exc: 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( log_lines.append(
f"{_now()} | parsed {file_name}: {status} | " f"{_now()} | parsed {file_name}: {status} | "
f"{file_number}/{total_files} | " f"{file_number}/{total_files} | "
@ -322,10 +341,10 @@ def run_batch(
try: try:
# Номер запуска для папки дня в OpenCloud определяем по # Номер запуска для папки дня в OpenCloud определяем по
# самому OpenCloud — см. next_opencloud_run_number. # самому OpenCloud — см. next_opencloud_run_number.
# opencloud_space_id получен в начале run_batch (проверка # personal_drive получен в начале run_batch (проверка
# доступа до обработки). # доступа до обработки).
run_number = next_opencloud_run_number( run_number = next_opencloud_run_number(
opencloud_space_id, personal_drive.id,
(DEFAULT_OUTPUT_DIR_NAME, year, month, day), (DEFAULT_OUTPUT_DIR_NAME, year, month, day),
username=opencloud_username, username=opencloud_username,
token=opencloud_access_token, token=opencloud_access_token,
@ -333,14 +352,14 @@ def run_batch(
report_file_name = build_report_file_name(date_key, run_number) report_file_name = build_report_file_name(date_key, run_number)
protocol_file_name = build_protocol_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) 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_report=local_report,
local_protocol=local_protocol, local_protocol=local_protocol,
local_log=local_log, local_log=local_log,
report_file_name=report_file_name, report_file_name=report_file_name,
protocol_file_name=protocol_file_name, protocol_file_name=protocol_file_name,
log_file_name=log_file_name, log_file_name=log_file_name,
space_id=opencloud_space_id, personal_drive=personal_drive,
opencloud_username=opencloud_username, opencloud_username=opencloud_username,
opencloud_access_token=opencloud_access_token, opencloud_access_token=opencloud_access_token,
year=year, year=year,
@ -393,6 +412,11 @@ def run_batch(
messages.append(f"Пропущено повторно прикреплённых файлов: {skipped}.") messages.append(f"Пропущено повторно прикреплённых файлов: {skipped}.")
if attachment_failures: if attachment_failures:
messages.append("Не обработаны вложения: " + "; ".join(attachment_failures)) messages.append("Не обработаны вложения: " + "; ".join(attachment_failures))
if uploaded.has_fallback_links:
messages.append(
"Не удалось получить ссылку на файл в веб-интерфейсе OpenCloud — "
"такая кнопка откроет папку с результатами."
)
duration_seconds = round(time.perf_counter() - started_at, 2) duration_seconds = round(time.perf_counter() - started_at, 2)
messages.append(f"Время выполнения: {_format_duration(duration_seconds)}.") messages.append(f"Время выполнения: {_format_duration(duration_seconds)}.")
messages.extend(log_lines[-5:]) messages.extend(log_lines[-5:])
@ -401,11 +425,15 @@ def run_batch(
processed=processed, processed=processed,
partial=partial, partial=partial,
errors=errors, errors=errors,
report_path=report_path, report_path=uploaded.report.dav_url,
protocol_path=protocol_path, protocol_path=uploaded.protocol.dav_url,
log_path=log_path, log_path=uploaded.log.dav_url,
messages=messages, messages=messages,
duration_seconds=duration_seconds, 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, report_file_name: str,
protocol_file_name: str, protocol_file_name: str,
log_file_name: str, log_file_name: str,
space_id: str, personal_drive: PersonalDrive,
opencloud_username: str, opencloud_username: str,
opencloud_access_token: str, opencloud_access_token: str,
year: str, year: str,
month: str, month: str,
day: str, day: str,
run_number: int, run_number: int,
) -> tuple[str, str, str]: ) -> _UploadedArtifacts:
# space_id (личное пространство пользователя) резолвится вызывающим # personal_drive (личное пространство пользователя) резолвится вызывающим
# кодом заново перед каждой загрузкой (не кешируем) — см. # кодом заново перед каждой загрузкой (не кешируем) — см.
# resolve_personal_drive_id. Кладём файлы по структуре # resolve_personal_drive. Кладём файлы по структуре
# Out/год/месяц/день/номер_запуска. # Out/год/месяц/день/номер_запуска.
# #
# opencloud_access_token здесь — App Token заказчика (не Bearer/ID-токен), # opencloud_access_token здесь — App Token заказчика (не Bearer/ID-токен),
# авторизация идёт через HTTP Basic Auth вместе с opencloud_username. # авторизация идёт через HTTP Basic Auth вместе с opencloud_username.
space_id = personal_drive.id
folder_segments = (DEFAULT_OUTPUT_DIR_NAME, year, month, day, str(run_number)) folder_segments = (DEFAULT_OUTPUT_DIR_NAME, year, month, day, str(run_number))
ensure_opencloud_folder( ensure_opencloud_folder(
space_id, space_id,
@ -450,7 +479,7 @@ def _upload_artifacts(
username=opencloud_username, username=opencloud_username,
token=opencloud_access_token, token=opencloud_access_token,
) )
report_url = upload_local_file_to_opencloud( report = upload_local_file_to_opencloud(
local_report, local_report,
space_id=space_id, space_id=space_id,
folder_segments=folder_segments, folder_segments=folder_segments,
@ -458,7 +487,7 @@ def _upload_artifacts(
username=opencloud_username, username=opencloud_username,
token=opencloud_access_token, token=opencloud_access_token,
) )
protocol_url = upload_local_file_to_opencloud( protocol = upload_local_file_to_opencloud(
local_protocol, local_protocol,
space_id=space_id, space_id=space_id,
folder_segments=folder_segments, folder_segments=folder_segments,
@ -466,7 +495,7 @@ def _upload_artifacts(
username=opencloud_username, username=opencloud_username,
token=opencloud_access_token, token=opencloud_access_token,
) )
log_url = upload_local_file_to_opencloud( log = upload_local_file_to_opencloud(
local_log, local_log,
space_id=space_id, space_id=space_id,
folder_segments=folder_segments, folder_segments=folder_segments,
@ -474,7 +503,69 @@ def _upload_artifacts(
username=opencloud_username, username=opencloud_username,
token=opencloud_access_token, 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: def _describe_opencloud_access_error(exc: Exception, username: str) -> str:

227
main.py
View File

@ -1,6 +1,8 @@
import asyncio import asyncio
import logging
import shutil import shutil
import tempfile import tempfile
from collections.abc import MutableMapping, Sequence
from pathlib import Path from pathlib import Path
import streamlit as st import streamlit as st
@ -12,12 +14,33 @@ from app.app_config import (
resolve_user_id, resolve_user_id,
) )
from app.auth.service import AuthService, clear_session 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.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: def main() -> None:
load_dotenv() 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.set_page_config(page_title="СФМ", page_icon=":lock:", layout="wide")
st.title("СФМ") st.title("СФМ")
@ -25,9 +48,12 @@ def main() -> None:
if not AuthService().run(): if not AuthService().run():
return return
_init_attachment_state(st.session_state)
_, center_col, _ = st.columns([1, 2, 1]) _, center_col, _ = st.columns([1, 2, 1])
with center_col: with center_col:
_render_token_popover() _render_token_popover()
_render_quota()
st.caption( st.caption(
"Прикрепите XML-файлы и/или архивы с ними (можно несколько сразу, " "Прикрепите XML-файлы и/или архивы с ними (можно несколько сразу, "
@ -39,16 +65,18 @@ def main() -> None:
"XML-файлы и архивы", "XML-файлы и архивы",
type=["xml", "zip", "tar", "gz", "tgz", "bz2", "xz"], type=["xml", "zip", "tar", "gz", "tgz", "bz2", "xz"],
accept_multiple_files=True, accept_multiple_files=True,
key=f"uploader_{st.session_state[UPLOADER_GENERATION_KEY]}",
) )
active_files = _render_attachment_list(uploaded_files or [])
if st.button( if st.button(
"Запустить обработку xml в excel", "Запустить обработку xml в excel",
type="primary", type="primary",
use_container_width=True, use_container_width=True,
disabled=not uploaded_files, disabled=not active_files,
): ):
with tempfile.TemporaryDirectory() as tmp_dir: 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="Подготовка файлов...") progress_bar = st.progress(0.0, text="Подготовка файлов...")
status_placeholder = st.empty() status_placeholder = st.empty()
@ -81,16 +109,7 @@ def main() -> None:
st.warning("Обработка завершена частично.") st.warning("Обработка завершена частично.")
else: else:
st.error("Обработка завершена с ошибками.") st.error("Обработка завершена с ошибками.")
st.write( _render_result_links(batch_result)
{
"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,
}
)
if batch_result.messages: if batch_result.messages:
st.caption("Последние сообщения обработки:") st.caption("Последние сообщения обработки:")
for message in batch_result.messages: for message in batch_result.messages:
@ -111,6 +130,185 @@ def main() -> None:
st.rerun() 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]: def _save_attachments(uploaded_files, target_dir: Path) -> list[Path]:
"""Складывает вложения на диск, каждое в свой подкаталог. """Складывает вложения на диск, каждое в свой подкаталог.
@ -160,6 +358,9 @@ def _render_token_popover() -> None:
st.error(f"Не удалось сохранить токен: {exc}") st.error(f"Не удалось сохранить токен: {exc}")
else: else:
st.success("Токен сохранён.") st.success("Токен сохранён.")
# С новым токеном квоту можно узнать заново (без токена
# в кеше лежит None).
st.session_state.pop(QUOTA_STATE_KEY, None)
if __name__ == "__main__": if __name__ == "__main__":

View 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"),
]

View File

@ -4,6 +4,7 @@ import tarfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from app.opencloud_access import OpenCloudUploadResult, PersonalDrive
from app.pipeline import service from app.pipeline import service
from app.pipeline.service import run_batch from app.pipeline.service import run_batch
@ -26,14 +27,22 @@ def _valid_xml() -> str:
def _fake_opencloud_upload( def _fake_opencloud_upload(
local_path: Path, space_id, folder_segments, file_name, username, token local_path: Path, space_id, folder_segments, file_name, username, token
) -> str: ) -> OpenCloudUploadResult:
assert local_path.exists() 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) _ = (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: def _fake_ensure_opencloud_folder(space_id, folder_segments, username, token) -> None:
@ -51,15 +60,16 @@ def _patch_opencloud(monkeypatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
service, "upload_local_file_to_opencloud", _fake_opencloud_upload service, "upload_local_file_to_opencloud", _fake_opencloud_upload
) )
monkeypatch.setattr( monkeypatch.setattr(service, "resolve_personal_drive", _fake_resolve_personal_drive)
service, "resolve_personal_drive_id", _fake_resolve_personal_drive_id
)
monkeypatch.setattr( monkeypatch.setattr(
service, "ensure_opencloud_folder", _fake_ensure_opencloud_folder service, "ensure_opencloud_folder", _fake_ensure_opencloud_folder
) )
monkeypatch.setattr( monkeypatch.setattr(
service, "next_opencloud_run_number", _fake_next_opencloud_run_number 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: def test_run_batch_uses_run_number_from_opencloud(tmp_path: Path, monkeypatch) -> None:

View File

@ -4,6 +4,9 @@ UploadedFile — это BytesIO: Streamlit уже держит всё содер
памяти. getvalue() делает ещё одну полную копию каждого файла перед записью памяти. getvalue() делает ещё одну полную копию каждого файла перед записью
на диск; при 230 вложениях в контейнере на 1 ГБ это лишняя пиковая нагрузка. на диск; при 230 вложениях в контейнере на 1 ГБ это лишняя пиковая нагрузка.
Копировать нужно потоково, через файловый интерфейс, не трогая getvalue(). Копировать нужно потоково, через файловый интерфейс, не трогая getvalue().
Исключение вложений из обработки (без удаления из file_uploader) и сброс
загрузчика — по file_id, потому что имена вложений могут совпадать.
""" """
from __future__ import annotations from __future__ import annotations
@ -11,7 +14,16 @@ from __future__ import annotations
import io import io
from pathlib import Path 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): 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) (path,) = _save_attachments([upload], tmp_path)
assert path.read_bytes() == b"0123456789" 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"}}

View File

@ -44,9 +44,9 @@ def test_upload_local_file_to_opencloud_success(tmp_path: Path) -> None:
local_file = tmp_path / "report.xlsx" local_file = tmp_path / "report.xlsx"
local_file.write_bytes(b"fake-xlsx-bytes") 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: 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, local_file,
space_id="abc$def", space_id="abc$def",
folder_segments=("Райков М.С.",), 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", token="coherent effects plausibly apple arming chef",
) )
assert url.endswith("/report.xlsx") assert result.dav_url.endswith("/report.xlsx")
_, kwargs = put.call_args _, kwargs = put.call_args
# Basic Auth (App Token), не Bearer — см. app/opencloud_access.py # Basic Auth (App Token), не Bearer — см. app/opencloud_access.py
assert "Authorization" not in kwargs["headers"] assert "Authorization" not in kwargs["headers"]

View 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"

View 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()