121 lines
5.6 KiB
Python
121 lines
5.6 KiB
Python
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from urllib.parse import quote
|
||
|
||
import requests
|
||
from requests.auth import HTTPBasicAuth
|
||
|
||
OPENCLOUD_DOMAIN = "docs.raisa.go.rshbank.ru"
|
||
OPENCLOUD_UPLOAD_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."""
|
||
|
||
|
||
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,
|
||
username: str,
|
||
token: str,
|
||
) -> str:
|
||
"""Загружает локальный файл в OpenCloud через WebDAV PUT (просто attach).
|
||
|
||
Возвращает URL, по которому файл загружен (для отображения пользователю /
|
||
записи в протокол).
|
||
|
||
АВТОРИЗАЦИЯ (подтверждено на проде 16.09, см. debug_access_token.py):
|
||
используется НЕ Bearer/ID-токен Keycloak, а HTTP Basic Auth — username +
|
||
долгоживущий App Token, который заказчик сам создаёт в веб-интерфейсе
|
||
OpenCloud. Bearer-токен от клиента datalab получал 401 от Graph API
|
||
OpenCloud (несовпадение audience) — App Token через Basic Auth работает,
|
||
т.к. идёт отдельным от OIDC путём авторизации.
|
||
|
||
ВРЕМЕННО (тестовый прогон): папку НЕ создаём (без MKCOL) — просто
|
||
кладём файл прямо в личную папку участника (folder_segments — как
|
||
правило один сегмент, например "Райков М.С."). Структуру Out/год/
|
||
месяц/день/номер_запуска вернём отдельным шагом после того, как
|
||
подтвердим на тесте базовый сценарий "файл долетает в нужную папку".
|
||
"""
|
||
if not username:
|
||
raise OpenCloudUploadError(
|
||
"Не задан username для авторизации в OpenCloud (Basic Auth)."
|
||
)
|
||
if not token:
|
||
raise OpenCloudUploadError(
|
||
"Отсутствует App Token для загрузки в OpenCloud. "
|
||
"Проверьте, что для этого пользователя сохранён токен "
|
||
"(app.opencloud_token_store)."
|
||
)
|
||
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 = {"Content-Type": content_type}
|
||
|
||
try:
|
||
with local_path.open("rb") as file_data:
|
||
response = requests.put(
|
||
url,
|
||
data=file_data,
|
||
headers=headers,
|
||
auth=HTTPBasicAuth(username, token),
|
||
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
|