- 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.
278 lines
12 KiB
Python
278 lines
12 KiB
Python
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
|