SFM/app/opencloud_access.py
Raykov-MS f47953273c Switch OpenCloud uploads to per-user personal drives
Files no longer go into a shared test space with a guessed folder name.
Instead, resolve each user's personal drive id via GET /graph/v1.0/me/drives
(Basic Auth, same App Token as uploads) on every run, and upload into
Out/год/месяц/день/номер_запуска inside that drive, creating the path via
MKCOL since a fresh personal drive won't have it yet.

Removes the now-obsolete OPENCLOUD_SPACE_ID / resolve_opencloud_user_folder
and the shared-space smoke script.
2026-09-17 13:37:51 +03:00

217 lines
9.9 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
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 resolve_personal_drive_id(username: str, token: str) -> str:
"""Находит ID личного пространства пользователя через Graph API.
Больше не грузим файлы в общее тестовое пространство по фиксированному
SPACE_ID — у каждого заказчика есть личное пространство (driveType ==
"personal" в GET /graph/v1.0/me/drives), и его ID нужно получать заново
перед каждой загрузкой: пространство может быть пересоздано, а хранить
потенциально устаревший ID рискованнее, чем сделать лишний HTTP-запрос.
Авторизация — та же Basic Auth (username + App Token), что и для самой
загрузки по WebDAV, см. upload_local_file_to_opencloud.
"""
if not username:
raise OpenCloudUploadError(
"Не задан username для поиска личного пространства в OpenCloud."
)
if not token:
raise OpenCloudUploadError(
"Отсутствует App Token для поиска личного пространства в OpenCloud."
)
url = f"https://{OPENCLOUD_DOMAIN}/graph/v1.0/me/drives"
try:
response = requests.get(
url,
auth=HTTPBasicAuth(username, token),
timeout=OPENCLOUD_UPLOAD_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise OpenCloudUploadError(
f"Ошибка сети при поиске личного пространства OpenCloud: {exc}"
) from exc
if response.status_code != 200:
raise OpenCloudUploadError(
"Не удалось получить список пространств OpenCloud: "
f"HTTP {response.status_code}, body={response.text[:500]!r}"
)
drives = response.json().get("value", [])
personal_drives = [
drive for drive in drives if drive.get("driveType") == "personal"
]
if not personal_drives:
raise OpenCloudUploadError(
f"Не найдено личное пространство (driveType='personal') для "
f"пользователя '{username}' в OpenCloud."
)
drive_id = personal_drives[0].get("id")
if not drive_id:
raise OpenCloudUploadError(
"Личное пространство найдено в OpenCloud, но у него отсутствует id."
)
return str(drive_id)
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 ensure_opencloud_folder(
space_id: str,
folder_segments: tuple[str, ...] | list[str],
username: str,
token: str,
) -> None:
"""Создаёт папку folder_segments в личном пространстве через MKCOL.
WebDAV PUT возвращает 409 Conflict, если хотя бы одна промежуточная
коллекция ещё не существует, поэтому перед первой загрузкой в новый
путь (Out/год/месяц/день/номер_запуска) создаём его сегмент за
сегментом — так же, как ensure_dir_exists делает это для SMB.
"""
segments = [segment for segment in folder_segments if segment]
accumulated: list[str] = []
for segment in segments:
accumulated.append(segment)
encoded_path = "/".join(quote(part, safe="") for part in accumulated)
url = f"https://{OPENCLOUD_DOMAIN}/dav/spaces/{space_id}/{encoded_path}"
try:
response = requests.request(
"MKCOL",
url,
auth=HTTPBasicAuth(username, token),
timeout=OPENCLOUD_UPLOAD_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise OpenCloudUploadError(
f"Ошибка сети при создании папки в OpenCloud: url='{url}', {exc}"
) from exc
# 201 — папка создана, 405 — уже существует (MKCOL на существующую
# коллекцию запрещён спецификацией WebDAV, это не ошибка для нас).
if response.status_code not in (201, 405):
raise OpenCloudUploadError(
"Не удалось создать папку в OpenCloud: "
f"HTTP {response.status_code}, url='{url}', "
f"body={response.text[:500]!r}"
)
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 путём авторизации.
Промежуточные папки (folder_segments) должны быть уже созданы через
ensure_opencloud_folder — сам PUT их не создаёт и вернёт 409, если
хотя бы одна коллекция на пути отсутствует.
"""
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