161 lines
6.9 KiB
Python
161 lines
6.9 KiB
Python
import asyncio
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
import streamlit as st
|
||
from dotenv import load_dotenv
|
||
|
||
from app.app_config import (
|
||
resolve_opencloud_access_token,
|
||
resolve_opencloud_username,
|
||
resolve_user_id,
|
||
)
|
||
from app.auth.service import AuthService, clear_session
|
||
from app.opencloud_token_store import OpenCloudTokenStoreError, add_token
|
||
from app.pipeline.service import run_batch
|
||
|
||
|
||
def main() -> None:
|
||
load_dotenv()
|
||
|
||
st.set_page_config(page_title="СФМ", page_icon=":lock:", layout="wide")
|
||
st.title("СФМ")
|
||
|
||
if not AuthService().run():
|
||
return
|
||
|
||
_, center_col, _ = st.columns([1, 2, 1])
|
||
with center_col:
|
||
_render_token_popover()
|
||
|
||
st.caption(
|
||
"Прикрепите XML-файлы и/или архивы с ними (можно несколько сразу, "
|
||
"вперемешку) — отчёт, протокол и лог загрузятся в Out вашего "
|
||
"личного пространства OpenCloud."
|
||
)
|
||
|
||
uploaded_files = st.file_uploader(
|
||
"XML-файлы и архивы",
|
||
type=["xml", "zip", "tar", "gz", "tgz", "bz2", "xz"],
|
||
accept_multiple_files=True,
|
||
)
|
||
|
||
if st.button(
|
||
"Запустить обработку xml в excel",
|
||
type="primary",
|
||
use_container_width=True,
|
||
disabled=not uploaded_files,
|
||
):
|
||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||
input_paths = _save_attachments(uploaded_files, Path(tmp_dir))
|
||
progress_bar = st.progress(0.0, text="Подготовка файлов...")
|
||
status_placeholder = st.empty()
|
||
|
||
def _on_progress(done: int, total: int, file_name: str) -> None:
|
||
# Прогресс по файлам, а не один спиннер на весь батч —
|
||
# иначе при большом количестве файлов (были жалобы на
|
||
# батч в 230 штук) непонятно, идёт обработка или
|
||
# приложение зависло, и пользователь закрывает вкладку
|
||
# посреди обработки.
|
||
fraction = done / total if total else 1.0
|
||
progress_bar.progress(
|
||
fraction, text=f"Обработано {done} из {total}: {file_name}"
|
||
)
|
||
status_placeholder.caption(
|
||
f"Последний обработанный файл: {file_name}"
|
||
)
|
||
|
||
launcher_name = resolve_opencloud_username() or resolve_user_id()
|
||
batch_result = run_batch(
|
||
input_paths=input_paths,
|
||
launcher=launcher_name,
|
||
opencloud_username=resolve_opencloud_username(),
|
||
opencloud_access_token=resolve_opencloud_access_token(),
|
||
progress_callback=_on_progress,
|
||
)
|
||
progress_bar.progress(1.0, text="Готово")
|
||
if batch_result.status == "success":
|
||
st.success("Обработка завершена успешно.")
|
||
elif batch_result.status == "partial":
|
||
st.warning("Обработка завершена частично.")
|
||
else:
|
||
st.error("Обработка завершена с ошибками.")
|
||
st.write(
|
||
{
|
||
"processed": batch_result.processed,
|
||
"partial": batch_result.partial,
|
||
"errors": batch_result.errors,
|
||
"report_path": batch_result.report_path,
|
||
"protocol_path": batch_result.protocol_path,
|
||
"log_path": batch_result.log_path,
|
||
}
|
||
)
|
||
if batch_result.messages:
|
||
st.caption("Последние сообщения обработки:")
|
||
for message in batch_result.messages:
|
||
st.write(f"- {message}")
|
||
# Если обработка прошла, а загрузка в OpenCloud — нет, отдаём
|
||
# результат напрямую, чтобы не терять долгую обработку.
|
||
for file_name, payload in batch_result.fallback_files.items():
|
||
st.download_button(
|
||
f"Скачать {file_name}",
|
||
data=payload,
|
||
file_name=file_name,
|
||
use_container_width=True,
|
||
key=f"fallback_{file_name}",
|
||
)
|
||
|
||
if st.button("Выйти", type="secondary"):
|
||
clear_session()
|
||
st.rerun()
|
||
|
||
|
||
def _save_attachments(uploaded_files, target_dir: Path) -> list[Path]:
|
||
"""Складывает вложения на диск, каждое в свой подкаталог.
|
||
|
||
Подкаталог по индексу нужен потому, что имена вложений могут совпадать
|
||
(например, два архива "Файлы.zip" из разных папок), и одно перезаписало
|
||
бы другое.
|
||
"""
|
||
paths: list[Path] = []
|
||
for index, uploaded in enumerate(uploaded_files):
|
||
holder = target_dir / str(index)
|
||
holder.mkdir(parents=True, exist_ok=True)
|
||
path = holder / uploaded.name
|
||
path.write_bytes(uploaded.getvalue())
|
||
paths.append(path)
|
||
return paths
|
||
|
||
|
||
def _render_token_popover() -> None:
|
||
"""Самообслуживание: пользователь сам привязывает свой App Token
|
||
OpenCloud к своей учётке (по preferred_username из Keycloak), без
|
||
обращения к администратору с CLI-скриптом.
|
||
"""
|
||
with st.popover("Добавить токен"):
|
||
username = resolve_opencloud_username()
|
||
if not username:
|
||
st.error("Не удалось определить вашу учётную запись.")
|
||
return
|
||
|
||
token = st.text_input(
|
||
"App Token",
|
||
type="password",
|
||
help="Долгоживущий App Token из веб-интерфейса OpenCloud (6 слов через пробел).",
|
||
key="opencloud_token_input",
|
||
)
|
||
if st.button("Сохранить", key="opencloud_token_save"):
|
||
if not token:
|
||
st.error("Введите токен.")
|
||
else:
|
||
try:
|
||
asyncio.run(add_token(username, token, added_by=username))
|
||
except OpenCloudTokenStoreError as exc:
|
||
st.error(f"Не удалось сохранить токен: {exc}")
|
||
else:
|
||
st.success("Токен сохранён.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|