The uploader took a single .zip, so a day split across several archives, or a handful of loose XML files, needed one run each. - main.py and scripts/local_zip_test_app.py now use accept_multiple_files and take .xml alongside .zip and the tar family. Each attachment is written to its own temp subdirectory, because two attachments may share a name. - run_batch takes input_paths instead of zip_path. - New app/pipeline/attachments.py flattens the attachments into a list of XML files. Archive format is detected from the content rather than the suffix, since exports arrive with the wrong extension often enough. .rar and .7z are recognised only to explain why they are refused. - A broken attachment no longer aborts the run. It becomes one protocol row and the remaining attachments are still processed; the reason is also shown in the UI, not only in the protocol file. - Files repeated across attachments are processed once and the repeats are recorded with status "skip". Attaching the same archive twice would otherwise double every row of the report. - Archives are unpacked member by member with the target path checked against the extraction directory. tarfile.data_filter only exists from Python 3.11.4 and extractall would otherwise let a "../" member escape. - The protocol gains a "Вложение" column, so it is clear which attachment a file came from when names repeat across archives. Checked end to end on the 03.02.2023 export: six attachments (two archives, two loose XML files, one broken archive, one duplicate archive) give 57 files processed, 9 repeats skipped, one attachment refused with its reason, and the report is still produced.
133 lines
5.2 KiB
Python
133 lines
5.2 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))
|
||
with st.spinner("Выполняется обработка..."):
|
||
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(),
|
||
)
|
||
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}")
|
||
|
||
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()
|