529 lines
23 KiB
Python
529 lines
23 KiB
Python
from __future__ import annotations
|
||
|
||
import logging
|
||
import xml.etree.ElementTree as ET
|
||
from collections.abc import Mapping
|
||
from dataclasses import dataclass
|
||
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"
|
||
|
||
_OC_NAMESPACE = "http://owncloud.org/ns"
|
||
# Свойства, по которым OpenCloud (как и ownCloud) отдаёт ссылку для
|
||
# веб-интерфейса: oc:privatelink — готовая ссылка, oc:fileid — ID, из которого
|
||
# её можно собрать (см. build_opencloud_private_link).
|
||
_PRIVATE_LINK_PROPFIND_BODY = (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
f'<d:propfind xmlns:d="DAV:" xmlns:oc="{_OC_NAMESPACE}">'
|
||
"<d:prop><oc:privatelink/><oc:fileid/></d:prop>"
|
||
"</d:propfind>"
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class OpenCloudUploadError(RuntimeError):
|
||
"""Ошибка загрузки файла в OpenCloud по WebDAV."""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DriveQuota:
|
||
"""Квота личного пространства в байтах (поле quota диска в Graph API)."""
|
||
|
||
used: int
|
||
total: int | None = None
|
||
remaining: int | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PersonalDrive:
|
||
id: str
|
||
quota: DriveQuota | None = None
|
||
# driveAlias из Graph API ("personal/<логин>") — часть пути к пространству
|
||
# в веб-интерфейсе: /files/spaces/personal/<логин>/...
|
||
alias: str | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class OpenCloudUploadResult:
|
||
"""Куда загружен файл.
|
||
|
||
dav_url — WebDAV-адрес (/dav/spaces/...): по нему файл грузили, его же
|
||
пишем в протокол. Пользователю он бесполезен: браузер по нему просит
|
||
логин или скачивает файл. web_url — ссылка вида https://<домен>/f/<fileid>,
|
||
которая открывает файл в веб-интерфейсе OpenCloud; None, если её не
|
||
удалось получить.
|
||
"""
|
||
|
||
dav_url: str
|
||
web_url: str | None = None
|
||
|
||
|
||
def resolve_personal_drive_id(username: str, token: str) -> str:
|
||
"""ID личного пространства пользователя — см. resolve_personal_drive."""
|
||
return resolve_personal_drive(username, token).id
|
||
|
||
|
||
def resolve_personal_drive(username: str, token: str) -> PersonalDrive:
|
||
"""Находит личное пространство пользователя через 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."
|
||
)
|
||
|
||
personal_drive = personal_drives[0]
|
||
drive_id = personal_drive.get("id")
|
||
if not drive_id:
|
||
raise OpenCloudUploadError(
|
||
"Личное пространство найдено в OpenCloud, но у него отсутствует id."
|
||
)
|
||
drive_alias = personal_drive.get("driveAlias")
|
||
return PersonalDrive(
|
||
id=str(drive_id),
|
||
quota=_parse_drive_quota(personal_drive.get("quota")),
|
||
alias=str(drive_alias) if drive_alias else None,
|
||
)
|
||
|
||
|
||
def _parse_drive_quota(raw_quota: object) -> DriveQuota | None:
|
||
"""Разбирает поле quota диска из Graph API (used/total/remaining, байты).
|
||
|
||
Наличие и формат поля на нашем OpenCloud не проверены, поэтому всё, что не
|
||
похоже на квоту, даёт None — квота нужна только для отображения и не
|
||
должна ломать поиск пространства перед загрузкой.
|
||
"""
|
||
if not isinstance(raw_quota, Mapping):
|
||
logger.info("OpenCloud не вернул quota у личного пространства: %r", raw_quota)
|
||
return None
|
||
used = _non_negative_int(raw_quota.get("used"))
|
||
if used is None:
|
||
logger.info("В quota личного пространства нет поля used: %r", raw_quota)
|
||
return None
|
||
total = _non_negative_int(raw_quota.get("total"))
|
||
return DriveQuota(
|
||
used=used,
|
||
# 0 в total — квота не задана (безлимит), а не «0 байт».
|
||
total=total or None,
|
||
remaining=_non_negative_int(raw_quota.get("remaining")),
|
||
)
|
||
|
||
|
||
def _non_negative_int(value: object) -> int | None:
|
||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||
return None
|
||
if value < 0:
|
||
return None
|
||
return int(value)
|
||
|
||
|
||
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_folder_url(
|
||
space_id: str, folder_segments: tuple[str, ...] | list[str]
|
||
) -> str:
|
||
"""WebDAV-адрес папки /dav/spaces/{SPACE_ID}/{FOLDER}; сегменты кодируются
|
||
так же, как в build_opencloud_url."""
|
||
if not space_id:
|
||
raise OpenCloudUploadError("Не задан SPACE_ID для OpenCloud.")
|
||
encoded_folder = "/".join(
|
||
quote(segment, safe="") for segment in folder_segments if segment
|
||
)
|
||
path = f"/dav/spaces/{space_id}"
|
||
if encoded_folder:
|
||
path += f"/{encoded_folder}"
|
||
return f"https://{OPENCLOUD_DOMAIN}{path}"
|
||
|
||
|
||
def build_opencloud_web_folder_url(
|
||
drive_alias: str,
|
||
folder_segments: tuple[str, ...] | list[str],
|
||
file_id: str | None = None,
|
||
) -> str:
|
||
"""Ссылка на папку в веб-интерфейсе OpenCloud.
|
||
|
||
Формат взят из адресной строки браузера на нашем OpenCloud (29.09):
|
||
https://<домен>/files/spaces/personal/<логин>/Out/2026/9/29/1?fileId=...
|
||
Собирается без запросов к серверу, поэтому есть всегда. fileId папки
|
||
(если известен) добавляется так же, как его добавляет сам интерфейс.
|
||
"""
|
||
encoded_folder = "/".join(
|
||
quote(segment, safe="") for segment in folder_segments if segment
|
||
)
|
||
url = f"https://{OPENCLOUD_DOMAIN}/files/spaces/{quote(drive_alias, safe='/')}"
|
||
if encoded_folder:
|
||
url += f"/{encoded_folder}"
|
||
if file_id:
|
||
url += f"?fileId={_encode_file_id(file_id)}"
|
||
return url
|
||
|
||
|
||
def build_opencloud_private_link(file_id: str) -> str:
|
||
"""Ссылка вида https://<домен>/f/<fileid> на файл или папку.
|
||
|
||
Такую же ссылку OpenCloud отдаёт в oc:privatelink: она открывает ресурс в
|
||
веб-интерфейсе (с обычным входом через браузер), а не скачивает его.
|
||
"""
|
||
return f"https://{OPENCLOUD_DOMAIN}/f/{_encode_file_id(file_id)}"
|
||
|
||
|
||
def _encode_file_id(file_id: str) -> str:
|
||
# fileid в OpenCloud составной ("storageId$spaceId!opaqueId"); веб-интерфейс
|
||
# кодирует его целиком: "$" -> %24, "!" -> %21.
|
||
return quote(file_id, safe="")
|
||
|
||
|
||
def resolve_opencloud_web_link(dav_url: str, username: str, token: str) -> str | None:
|
||
"""Ссылка на ресурс в веб-интерфейсе OpenCloud через PROPFIND (Depth: 0).
|
||
|
||
privatelink, если он пришёл, возвращается как есть, иначе ссылка
|
||
собирается из fileid. Исключений не бросает (см. _propfind_link_properties):
|
||
при неудаче возвращает None, и вызывающий код подставляет запасную ссылку.
|
||
"""
|
||
properties = _propfind_link_properties(dav_url, username, token)
|
||
if properties is None:
|
||
return None
|
||
private_link, file_id = properties
|
||
if private_link:
|
||
return private_link
|
||
if file_id:
|
||
return build_opencloud_private_link(file_id)
|
||
logger.warning(
|
||
"PROPFIND не вернул ни oc:privatelink, ни oc:fileid: url='%s'", dav_url
|
||
)
|
||
return None
|
||
|
||
|
||
def resolve_opencloud_file_id(dav_url: str, username: str, token: str) -> str | None:
|
||
"""oc:fileid ресурса через PROPFIND (Depth: 0); None, если не удалось."""
|
||
properties = _propfind_link_properties(dav_url, username, token)
|
||
if properties is None:
|
||
return None
|
||
_, file_id = properties
|
||
if not file_id:
|
||
logger.warning("PROPFIND не вернул oc:fileid: url='%s'", dav_url)
|
||
return file_id
|
||
|
||
|
||
def _propfind_link_properties(
|
||
dav_url: str, username: str, token: str
|
||
) -> tuple[str | None, str | None] | None:
|
||
"""(oc:privatelink, oc:fileid) ресурса; None, если PROPFIND не удался.
|
||
|
||
Исключений не бросает: ссылки нужны только для удобства, и их отсутствие
|
||
не должно ронять обработку — любую проблему пишем в лог.
|
||
"""
|
||
try:
|
||
response = requests.request(
|
||
"PROPFIND",
|
||
dav_url,
|
||
auth=HTTPBasicAuth(username, token),
|
||
headers={"Depth": "0", "Content-Type": "application/xml; charset=utf-8"},
|
||
data=_PRIVATE_LINK_PROPFIND_BODY.encode("utf-8"),
|
||
timeout=OPENCLOUD_UPLOAD_TIMEOUT_SECONDS,
|
||
)
|
||
except requests.RequestException as exc:
|
||
logger.warning("PROPFIND за ссылкой не удался: url='%s', %s", dav_url, exc)
|
||
return None
|
||
|
||
if response.status_code != 207:
|
||
logger.warning(
|
||
"PROPFIND за ссылкой вернул HTTP %s: url='%s'",
|
||
response.status_code,
|
||
dav_url,
|
||
)
|
||
return None
|
||
|
||
try:
|
||
root = ET.fromstring(response.content)
|
||
except ET.ParseError as exc:
|
||
logger.warning(
|
||
"Не удалось разобрать ответ PROPFIND за ссылкой: url='%s', %s",
|
||
dav_url,
|
||
exc,
|
||
)
|
||
return None
|
||
|
||
# Отсутствующие свойства сервер возвращает пустыми элементами в propstat
|
||
# со статусом 404 — пустой текст означает «значения нет».
|
||
return _find_oc_property(root, "privatelink"), _find_oc_property(root, "fileid")
|
||
|
||
|
||
def _find_oc_property(root: ET.Element, name: str) -> str | None:
|
||
for element in root.iter(f"{{{_OC_NAMESPACE}}}{name}"):
|
||
text = (element.text or "").strip()
|
||
if text:
|
||
return text
|
||
return None
|
||
|
||
|
||
def _file_id_from_headers(headers: object) -> str | None:
|
||
"""Достаёт OC-FileId из заголовков ответа без учёта регистра имени.
|
||
|
||
requests отдаёт CaseInsensitiveDict, но на это не полагаемся: сервер или
|
||
прокси перед ним может прислать имя в любом регистре ("Oc-Fileid").
|
||
"""
|
||
if not isinstance(headers, Mapping):
|
||
return None
|
||
for name, value in headers.items():
|
||
if str(name).lower() == "oc-fileid" and value and str(value).strip():
|
||
return str(value).strip()
|
||
return None
|
||
|
||
|
||
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.")
|
||
|
||
folder_url = build_opencloud_folder_url(space_id, folder_segments)
|
||
return f"{folder_url}/{quote(file_name, safe='')}"
|
||
|
||
|
||
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,
|
||
) -> OpenCloudUploadResult:
|
||
"""Загружает локальный файл в OpenCloud через WebDAV PUT (просто attach).
|
||
|
||
Возвращает WebDAV-адрес, по которому файл загружен (для протокола), и
|
||
ссылку на файл в веб-интерфейсе OpenCloud (для пользователя). Ссылка
|
||
собирается из заголовка OC-FileId ответа на PUT, а если его нет — из
|
||
одного PROPFIND (resolve_opencloud_web_link). Если ссылку получить не
|
||
удалось, загрузка не падает: web_url будет None.
|
||
|
||
АВТОРИЗАЦИЯ (подтверждено на проде 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}"
|
||
)
|
||
|
||
file_id = _file_id_from_headers(getattr(response, "headers", None))
|
||
if file_id:
|
||
return OpenCloudUploadResult(
|
||
dav_url=url, web_url=build_opencloud_private_link(file_id)
|
||
)
|
||
logger.info(
|
||
"В ответе на PUT нет заголовка OC-FileId, запрашиваю ссылку через "
|
||
"PROPFIND: url='%s'",
|
||
url,
|
||
)
|
||
return OpenCloudUploadResult(
|
||
dav_url=url, web_url=resolve_opencloud_web_link(url, username, token)
|
||
)
|