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