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