174 lines
7.8 KiB
Python
174 lines
7.8 KiB
Python
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
|