SFM/app/pipeline/service.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

355 lines
13 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 tempfile
import time
import zipfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from ..opencloud_access import (
OpenCloudUploadError,
ensure_opencloud_folder,
next_opencloud_run_number,
resolve_personal_drive_id,
upload_local_file_to_opencloud,
)
from .config import (
DEFAULT_OUTPUT_DIR_NAME,
build_date_parts,
build_process_log_file_name,
build_protocol_file_name,
build_report_file_name,
)
from .parser import FileParseResult, parse_xml_content
from .protocol import ProtocolRow, write_protocol
from .report import ReportRow, StreamingReportWriter
EMPTY_BATCH_STATUS = "info"
@dataclass
class BatchResult:
status: str
processed: int
partial: int
errors: int
report_path: str
protocol_path: str
log_path: str
messages: list[str] = field(default_factory=list)
duration_seconds: float = 0.0
def run_batch(
zip_path: Path,
launcher: str,
opencloud_username: str | None,
opencloud_access_token: str | None,
) -> BatchResult:
"""Прикреплённый через интерфейс .zip с XML -> отчёт/протокол/лог -> OpenCloud.
Входные файлы приходят как есть — через вложение (attach) в интерфейсе
(Streamlit file_uploader), без SMB и без выделенной входной папки в
OpenCloud. Результат грузится в личное пространство пользователя
(Out/год/месяц/день/номер_запуска). Файлы с некорректным именем/битым
XML просто попадают в протокол со статусом "error" — никуда не
перемещаются (перемещать их и некуда, это временная распакованная
копия вложения).
"""
started_at = time.perf_counter()
if not opencloud_username:
return BatchResult(
status="error",
processed=0,
partial=0,
errors=1,
report_path="",
protocol_path="",
log_path="",
messages=["Не удалось определить username для авторизации в OpenCloud."],
)
if not opencloud_access_token:
return BatchResult(
status="error",
processed=0,
partial=0,
errors=1,
report_path="",
protocol_path="",
log_path="",
messages=[
"Не найден App Token OpenCloud для вашей учётки. "
"Добавьте его через кнопку «Добавить токен»."
],
)
protocol_rows: list[ProtocolRow] = []
log_lines: list[str] = [f"{_now()} | batch start: {zip_path.name}"]
with tempfile.TemporaryDirectory() as extract_dir:
extract_dir_path = Path(extract_dir)
try:
with zipfile.ZipFile(zip_path) as archive:
archive.extractall(extract_dir_path)
except zipfile.BadZipFile as exc:
return BatchResult(
status="error",
processed=0,
partial=0,
errors=1,
report_path="",
protocol_path="",
log_path="",
messages=[f"Не удалось распаковать архив: {exc}"],
)
xml_paths = sorted(extract_dir_path.rglob("*.xml"))
log_lines.append(
f"{_now()} | распаковано, найдено XML-файлов: {len(xml_paths)}"
)
report_writer = StreamingReportWriter()
for xml_path in xml_paths:
file_name = xml_path.name
try:
content = xml_path.read_bytes()
except Exception as exc: # noqa: BLE001
protocol_rows.append(
ProtocolRow(
file_name=file_name,
status="error",
message=f"Ошибка чтения файла из архива: {exc}",
operations_total=0,
rows_written=0,
launcher=launcher,
processed_at=datetime.now(),
)
)
log_lines.append(f"{_now()} | read failed: {file_name}. {exc}")
continue
# parse_xml_content сам проверяет имя файла и проставляет
# fatal_error, если оно не соответствует шаблону — отдельная
# валидация здесь не нужна.
parse_result = parse_xml_content(file_name=file_name, xml_content=content)
for row in parse_result.rows:
report_writer.append_row(
ReportRow(
file_name=row.file_name,
record_id=row.record_id,
operation_index=row.operation_index,
operation_fields=row.operation_fields,
participant_fields=row.participant_fields,
is_continuation=row.is_continuation,
)
)
status, message = _resolve_parse_status(parse_result)
protocol_rows.append(
ProtocolRow(
file_name=file_name,
status=status,
message=message,
operations_total=parse_result.total_operations,
rows_written=len(parse_result.rows),
launcher=launcher,
processed_at=datetime.now(),
)
)
log_lines.append(f"{_now()} | parsed {file_name}: {status}")
if not protocol_rows:
protocol_rows.append(
ProtocolRow(
file_name="-",
status=EMPTY_BATCH_STATUS,
message="В архиве не найдено XML-файлов.",
operations_total=0,
rows_written=0,
launcher=launcher,
processed_at=datetime.now(),
)
)
log_lines.append(f"{_now()} | empty batch")
year, month, day, date_key = build_date_parts()
with tempfile.TemporaryDirectory() as artifacts_dir:
artifacts_dir_path = Path(artifacts_dir)
local_report = artifacts_dir_path / "report.xlsx"
local_protocol = artifacts_dir_path / "protocol.xlsx"
local_log = artifacts_dir_path / "process.log"
report_writer.save(local_report)
write_protocol(protocol_rows, local_protocol)
local_log.write_text("\r\n".join(log_lines) + "\r\n", encoding="utf-8")
try:
# Номер запуска для папки дня в OpenCloud определяем по
# самому OpenCloud — см. next_opencloud_run_number.
opencloud_space_id = resolve_personal_drive_id(
opencloud_username, opencloud_access_token
)
run_number = next_opencloud_run_number(
opencloud_space_id,
(DEFAULT_OUTPUT_DIR_NAME, year, month, day),
username=opencloud_username,
token=opencloud_access_token,
)
report_file_name = build_report_file_name(date_key, run_number)
protocol_file_name = build_protocol_file_name(date_key, run_number)
log_file_name = build_process_log_file_name(date_key, run_number)
report_path, protocol_path, log_path = _upload_artifacts(
local_report=local_report,
local_protocol=local_protocol,
local_log=local_log,
report_file_name=report_file_name,
protocol_file_name=protocol_file_name,
log_file_name=log_file_name,
space_id=opencloud_space_id,
opencloud_username=opencloud_username,
opencloud_access_token=opencloud_access_token,
year=year,
month=month,
day=day,
run_number=run_number,
)
except OpenCloudUploadError as exc:
return BatchResult(
status="error",
processed=0,
partial=0,
errors=1,
report_path="",
protocol_path="",
log_path="",
messages=[
*log_lines,
f"Файлы обработаны, но не удалось загрузить результат "
f"в OpenCloud: {exc}",
],
)
processed = sum(1 for row in protocol_rows if row.status == "success")
partial = sum(1 for row in protocol_rows if row.status == "partial")
errors = sum(1 for row in protocol_rows if row.status == "error")
status = (
"success"
if errors == 0 and partial == 0
else ("partial" if partial > 0 else "error")
)
messages = [
_build_result_message(
status=status, processed=processed, partial=partial, errors=errors
)
]
duration_seconds = round(time.perf_counter() - started_at, 2)
messages.append(f"Время выполнения: {_format_duration(duration_seconds)}.")
messages.extend(log_lines[-5:])
return BatchResult(
status=status,
processed=processed,
partial=partial,
errors=errors,
report_path=report_path,
protocol_path=protocol_path,
log_path=log_path,
messages=messages,
duration_seconds=duration_seconds,
)
def _resolve_parse_status(parse_result: FileParseResult) -> tuple[str, str]:
if parse_result.fatal_error:
return "error", parse_result.fatal_error
if parse_result.operation_errors:
details = "; ".join(
f"операция {item.operation_index}: {item.reason}"
for item in parse_result.operation_errors
)
return "partial", details
return "success", "Успешно обработан."
def _upload_artifacts(
local_report: Path,
local_protocol: Path,
local_log: Path,
report_file_name: str,
protocol_file_name: str,
log_file_name: str,
space_id: str,
opencloud_username: str,
opencloud_access_token: str,
year: str,
month: str,
day: str,
run_number: int,
) -> tuple[str, str, str]:
# space_id (личное пространство пользователя) резолвится вызывающим
# кодом заново перед каждой загрузкой (не кешируем) — см.
# resolve_personal_drive_id. Кладём файлы по структуре
# Out/год/месяц/день/номер_запуска.
#
# opencloud_access_token здесь — App Token заказчика (не Bearer/ID-токен),
# авторизация идёт через HTTP Basic Auth вместе с opencloud_username.
folder_segments = (DEFAULT_OUTPUT_DIR_NAME, year, month, day, str(run_number))
ensure_opencloud_folder(
space_id,
folder_segments,
username=opencloud_username,
token=opencloud_access_token,
)
report_url = upload_local_file_to_opencloud(
local_report,
space_id=space_id,
folder_segments=folder_segments,
file_name=report_file_name,
username=opencloud_username,
token=opencloud_access_token,
)
protocol_url = upload_local_file_to_opencloud(
local_protocol,
space_id=space_id,
folder_segments=folder_segments,
file_name=protocol_file_name,
username=opencloud_username,
token=opencloud_access_token,
)
log_url = upload_local_file_to_opencloud(
local_log,
space_id=space_id,
folder_segments=folder_segments,
file_name=log_file_name,
username=opencloud_username,
token=opencloud_access_token,
)
return report_url, protocol_url, log_url
def _now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _build_result_message(
*, status: str, processed: int, partial: int, errors: int
) -> str:
if status == "success":
if processed > 0:
return f"Все успешно: обработано файлов {processed}."
return "Успешно: новых файлов для обработки нет."
if status == "partial":
return (
"Частично успешно: "
f"успешно={processed}, частично={partial}, с ошибками={errors}."
)
return f"Ошибка обработки: успешно={processed}, частично={partial}, с ошибками={errors}."
def _format_duration(duration_seconds: float) -> str:
total_seconds = max(0, int(round(duration_seconds)))
minutes, seconds = divmod(total_seconds, 60)
if minutes:
return f"{minutes} мин {seconds} сек"
return f"{seconds} сек"