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