SFM/app/opencloud_access.py
2026-09-14 17:25:05 +03:00

174 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from pathlib import Path
from urllib.parse import quote
import requests
OPENCLOUD_DOMAIN = "docs.raisa.go.rshbank.ru"
OPENCLOUD_UPLOAD_TIMEOUT_SECONDS = 30
OPENCLOUD_GRAPH_TIMEOUT_SECONDS = 30
# Content-Type подобран по расширению — сервер OpenCloud (как и обычный WebDAV)
# не требует конкретного значения для PUT, но так удобнее просматривать файлы
# в интерфейсе.
_CONTENT_TYPES = {
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
".log": "text/plain; charset=utf-8",
}
_DEFAULT_CONTENT_TYPE = "application/octet-stream"
class OpenCloudUploadError(RuntimeError):
"""Ошибка загрузки файла в OpenCloud по WebDAV."""
class OpenCloudDriveResolutionError(RuntimeError):
"""Ошибка получения личного пространства (drive) пользователя в OpenCloud."""
def resolve_personal_drive(access_token: str) -> dict:
"""Находит личное пространство пользователя через Graph API OpenCloud.
Личное пространство в OpenCloud — не подпапка внутри общего пространства,
а отдельный drive (driveType == "personal") со своим составным id вида
"{storageId}${spaceId}". Эта функция делает GET /graph/v1.0/me/drives,
выбирает запись с driveType == "personal" и возвращает словарь с ключами
"id" (SPACE_ID для загрузки) и "web_dav_url" (готовый базовый WebDAV URL
из root.webDavUrl, на случай если он понадобится вместо ручной сборки).
"""
if not access_token:
raise OpenCloudDriveResolutionError(
"Отсутствует access_token для запроса личного пространства OpenCloud."
)
url = f"https://{OPENCLOUD_DOMAIN}/graph/v1.0/me/drives"
headers = {"Authorization": f"Bearer {access_token}"}
try:
response = requests.get(
url, headers=headers, timeout=OPENCLOUD_GRAPH_TIMEOUT_SECONDS
)
except requests.RequestException as exc:
raise OpenCloudDriveResolutionError(
f"Ошибка сети при запросе списка пространств OpenCloud: {exc}"
) from exc
if response.status_code == 401:
raise OpenCloudDriveResolutionError(
"OpenCloud отклонил access_token (HTTP 401) при запросе "
"списка пространств."
)
if response.status_code != 200:
raise OpenCloudDriveResolutionError(
"Не удалось получить список пространств OpenCloud: "
f"HTTP {response.status_code}, body={response.text[:500]!r}"
)
try:
drives = response.json().get("value", [])
except ValueError as exc:
raise OpenCloudDriveResolutionError(
f"Некорректный JSON-ответ при запросе списка пространств OpenCloud: {exc}"
) from exc
for drive in drives:
if not isinstance(drive, dict) or drive.get("driveType") != "personal":
continue
drive_id = drive.get("id")
if not drive_id:
continue
web_dav_url = (drive.get("root") or {}).get("webDavUrl")
return {"id": drive_id, "web_dav_url": web_dav_url}
raise OpenCloudDriveResolutionError(
'Личное пространство (driveType="personal") не найдено среди '
"пространств пользователя в OpenCloud."
)
def build_opencloud_url(
space_id: str, folder_segments: tuple[str, ...] | list[str], file_name: str
) -> str:
"""Собирает URL вида /dav/spaces/{SPACE_ID}/{FOLDER}/{FILENAME}.
Каждый сегмент папки и имя файла кодируются ОТДЕЛЬНО через urllib.parse.quote —
requests не кодирует кириллицу/пробелы в уже собранном URL так, как нужно
серверу (проверено вручную, см. open_cloud_upload_fixed.py).
space_id передаётся как есть — по факту наблюдений это составной идентификатор
"storageId$spaceId" (обе части через "$"), но эта функция его не разбирает
и не проверяет формат, чтобы не завязываться на недокументированное поведение.
"""
if not space_id:
raise OpenCloudUploadError("Не задан SPACE_ID для OpenCloud.")
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}"
def upload_local_file_to_opencloud(
local_path: Path,
space_id: str,
folder_segments: tuple[str, ...] | list[str],
file_name: str,
access_token: str,
) -> str:
"""Загружает локальный файл в OpenCloud через WebDAV PUT (просто attach).
Возвращает URL, по которому файл загружен (для отображения пользователю /
записи в протокол).
ВРЕМЕННО (тестовый прогон): папку НЕ создаём (без MKCOL) — просто
кладём файл прямо в личную папку участника (folder_segments — как
правило один сегмент, например "Райков М.С."). Структуру Out/год/
месяц/день/номер_запуска вернём отдельным шагом после того, как
подтвердим на тесте базовый сценарий "файл долетает в нужную папку".
"""
if not access_token:
raise OpenCloudUploadError(
"Отсутствует access_token для загрузки в OpenCloud. "
"Проверьте наличие ключа 'access_token' в st.session_state['token_data']."
)
if not local_path.exists():
raise OpenCloudUploadError(f"Локальный файл не найден: {local_path}")
url = build_opencloud_url(space_id, folder_segments, file_name)
content_type = _CONTENT_TYPES.get(local_path.suffix.lower(), _DEFAULT_CONTENT_TYPE)
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": content_type,
}
try:
with local_path.open("rb") as file_data:
response = requests.put(
url,
data=file_data,
headers=headers,
timeout=OPENCLOUD_UPLOAD_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise OpenCloudUploadError(
f"Ошибка сети при загрузке в OpenCloud: url='{url}', {exc}"
) from exc
if response.status_code not in (200, 201, 204):
raise OpenCloudUploadError(
"Загрузка в OpenCloud не удалась: "
f"HTTP {response.status_code}, url='{url}', "
f"body={response.text[:500]!r}"
)
return url