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