from __future__ import annotations import xml.etree.ElementTree as ET from pathlib import Path from urllib.parse import quote, unquote, urlparse 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 next_opencloud_run_number( space_id: str, day_folder_segments: tuple[str, ...] | list[str], username: str, token: str, ) -> int: """Определяет следующий номер запуска внутри Out/год/месяц/день. Листит папку дня через WebDAV PROPFIND (Depth: 1) и берёт максимальное числовое имя подпапки + 1. Если папки дня ещё нет (первый запуск за день) — PROPFIND вернёт 404, считаем, что это первый запуск. """ encoded_path = "/".join( quote(segment, safe="") for segment in day_folder_segments if segment ) url = f"https://{OPENCLOUD_DOMAIN}/dav/spaces/{space_id}/{encoded_path}" try: response = requests.request( "PROPFIND", url, auth=HTTPBasicAuth(username, token), headers={"Depth": "1"}, timeout=OPENCLOUD_UPLOAD_TIMEOUT_SECONDS, ) except requests.RequestException as exc: raise OpenCloudUploadError( f"Ошибка сети при определении номера запуска в OpenCloud: {exc}" ) from exc if response.status_code == 404: return 1 if response.status_code != 207: raise OpenCloudUploadError( "Не удалось получить содержимое папки дня в OpenCloud: " f"HTTP {response.status_code}, url='{url}', " f"body={response.text[:500]!r}" ) try: root = ET.fromstring(response.content) except ET.ParseError as exc: raise OpenCloudUploadError( f"Не удалось разобрать ответ PROPFIND от OpenCloud: {exc}" ) from exc requested_path = urlparse(url).path.rstrip("/") numbers: list[int] = [] for element in root.iter(): if element.tag.rsplit("}", 1)[-1] != "href": continue href_path = urlparse(element.text or "").path.rstrip("/") if href_path == requested_path: # Первая запись PROPFIND (Depth: 1) — сама папка дня, не ребёнок. continue name = unquote(href_path.rsplit("/", 1)[-1]) if name.isdigit(): numbers.append(int(name)) return max(numbers, default=0) + 1 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/год/месяц/день/номер_запуска) создаём его сегмент за сегментом. """ 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