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/
.env
.env.example.superpowers/
.env.example
.superpowers/
.bounded
.DS_Store

View File

@ -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)
)

View File

@ -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
View File

@ -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__":

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
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:

View File

@ -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"}}

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.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"]

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