SFM/app/opencloud_access.py
Raykov-MS 7b90daa396 Remove SMB entirely, switch input to attached zip, add self-service token popover
- Drop SMB/SAMBA subsystem completely: app/samba_access.py, app/path_settings.py,
  app/path_store.py, app/pipeline/file_manager.py and their tests, plus the
  smbprotocol dependency and all related UI (login/password fields, path editor,
  SMB diagnostics) in main.py.
- run_batch now takes a .zip attached via Streamlit's file_uploader (same as the
  local dev tool) instead of reading from SMB or a fictitious OpenCloud "Attach"
  folder; renamed from run_local_zip_batch since it's now the single canonical
  batch entrypoint used by both main.py and scripts/local_zip_test_app.py.
- Fixed run number resolution: added next_opencloud_run_number() which lists the
  Out/year/month/day folder via WebDAV PROPFIND, so repeated runs land in
  incrementing subfolders instead of always overwriting "1".
- Added a self-service "Добавить токен" popover in main.py so users can link
  their own OpenCloud App Token to their Keycloak account (opencloud_token_store)
  without an admin running the CLI script; reworded the missing-token error to
  point at it.
2026-09-17 15:34:53 +03:00

278 lines
12 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
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