From 7dbfb29a2159789278a3bf0c9fdad8f9aa719320 Mon Sep 17 00:00:00 2001 From: Server <10.9.8.204@example.com> Date: Sun, 30 Aug 2026 13:09:59 +0000 Subject: [PATCH] add admin schedule uploads --- bot/__init__.py | 1 + bot/admin_schedule.py | 175 ++++++++++++++++++++++++++++++++++++++++++ bot/iternal.py | 7 +- 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 bot/admin_schedule.py diff --git a/bot/__init__.py b/bot/__init__.py index 20e5c45..cc4e0d0 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -22,6 +22,7 @@ from .main.register_user import * # #? Another code from .analytics import * from .iternal import * +from .admin_schedule import * from .inline import * from .main.home import * from .schedule.search import * diff --git a/bot/admin_schedule.py b/bot/admin_schedule.py new file mode 100644 index 0000000..c85bb24 --- /dev/null +++ b/bot/admin_schedule.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import html +import json +import secrets +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import requests +from telebot.types import CallbackQuery, Message +from telebot.util import quick_markup + +import config +from models import logger +from models.bot import bot, send +from models.schedule import clear_schedule_cache + + +@dataclass +class PendingArchive: + admin_id: int + filename: str + path: Path + + +PENDING_ARCHIVES: dict[str, PendingArchive] = {} +UPLOAD_CALLBACK_PREFIX = "$schedule_upload:" +CANCEL_CALLBACK_PREFIX = "$schedule_cancel:" + + +def _is_admin(user_id: int) -> bool: + return user_id in config.ADMINS + + +def _api_url(path: str) -> str: + return f"{config.SCHEDULE_BASE_URL.rstrip('/')}/{path.lstrip('/')}" + + +def _download_archive(message: Message) -> PendingArchive: + file_info = bot.get_file(message.document.file_id) + content = bot.download_file(file_info.file_path) + filename = message.document.file_name or "schedule.zip" + with tempfile.NamedTemporaryFile(prefix="zatups-schedule-", suffix=".zip", delete=False) as file: + file.write(content) + path = Path(file.name) + return PendingArchive(message.from_user.id, filename, path) + + +def _upload_archive(archive: PendingArchive) -> dict[str, Any]: + with archive.path.open("rb") as file: + response = requests.post( + _api_url("schedule/default/upload"), + files={"file": (archive.filename, file, "application/zip")}, + timeout=(10, 300), + ) + response.raise_for_status() + return response.json() + + +def _clear_database() -> dict[str, Any]: + response = requests.delete( + _api_url("admin/database"), + params={"confirm": "DELETE_ALL_DATA"}, + timeout=30, + ) + response.raise_for_status() + return response.json() + + +def _format_upload_result(payload: dict[str, Any]) -> str: + serialized = json.dumps(payload, ensure_ascii=False, indent=2) + if len(serialized) > 3400: + serialized = serialized[:3400] + "\n..." + heading = "База применена" if payload.get("ok") else "Архив обработан с ошибками" + return f"{heading}, результат:\n
{html.escape(serialized)}
" + + +def _format_request_error(error: requests.RequestException) -> str: + response = error.response + if response is None: + return str(error) + try: + detail = response.json() + except ValueError: + detail = response.text + return json.dumps(detail, ensure_ascii=False) if isinstance(detail, dict) else str(detail) + + +@bot.message_handler( + content_types=["document", "file"], + func=lambda message: _is_admin(message.from_user.id) + and bool(message.document) + and (message.document.file_name or "").lower().endswith(".zip"), +) +def schedule_archive(message: Message): + try: + archive = _download_archive(message) + except Exception as error: + logger.error("Schedule", f"Archive download failed: {error}") + return send(message, f"Не удалось скачать архив: {html.escape(str(error))}") + + token = secrets.token_urlsafe(8) + PENDING_ARCHIVES[token] = archive + return send( + message, + "Вы уверены, что хотите отправить архив в API?\n" + "Сбросить БД в API можно командой /reset_schedule_db", + reply_markup=quick_markup( + { + "Отправить": {"callback_data": UPLOAD_CALLBACK_PREFIX + token}, + "Отменить": {"callback_data": CANCEL_CALLBACK_PREFIX + token}, + }, + row_width=1, + ), + ) + + +@bot.callback_query_handler(func=lambda call: str(call.data).startswith(CANCEL_CALLBACK_PREFIX)) +def cancel_schedule_upload(call: CallbackQuery): + if not _is_admin(call.from_user.id): + return + token = str(call.data).removeprefix(CANCEL_CALLBACK_PREFIX) + archive = PENDING_ARCHIVES.get(token) + if archive and archive.admin_id == call.from_user.id: + PENDING_ARCHIVES.pop(token, None) + archive.path.unlink(missing_ok=True) + bot.answer_callback_query(call.id, "Отменено") + + +@bot.callback_query_handler(func=lambda call: str(call.data).startswith(UPLOAD_CALLBACK_PREFIX)) +def upload_schedule_archive(call: CallbackQuery): + if not _is_admin(call.from_user.id): + return + token = str(call.data).removeprefix(UPLOAD_CALLBACK_PREFIX) + archive = PENDING_ARCHIVES.get(token) + if not archive or archive.admin_id != call.from_user.id: + return bot.answer_callback_query(call.id, "Архив не найден или уже отправлен") + + bot.answer_callback_query(call.id, "Отправляю архив") + try: + payload = _upload_archive(archive) + except requests.RequestException as error: + logger.error("Schedule", f"Archive upload failed: {error}") + return send( + call.from_user.id, + f"API отклонил архив: {html.escape(_format_request_error(error))}", + ) + except (OSError, ValueError) as error: + logger.error("Schedule", f"Archive upload failed: {error}") + return send(call.from_user.id, f"Не удалось отправить архив: {html.escape(str(error))}") + + PENDING_ARCHIVES.pop(token, None) + archive.path.unlink(missing_ok=True) + clear_schedule_cache() + return send(call.from_user.id, _format_upload_result(payload)) + + +@bot.message_handler(commands=["reset_schedule_db"], func=lambda message: _is_admin(message.from_user.id)) +def reset_schedule_database(message: Message): + try: + _clear_database() + except requests.RequestException as error: + logger.error("Schedule", f"Database reset failed: {error}") + return send( + message, + f"Не удалось очистить базу: {html.escape(_format_request_error(error))}", + ) + except ValueError as error: + logger.error("Schedule", f"Database reset failed: {error}") + return send(message, f"API вернул некорректный ответ: {html.escape(str(error))}") + + clear_schedule_cache() + return send(message, "База очищена") diff --git a/bot/iternal.py b/bot/iternal.py index 82a58c1..aa13387 100644 --- a/bot/iternal.py +++ b/bot/iternal.py @@ -47,7 +47,12 @@ def sendConfirm(message: Message): 'Нeт': {'callback_data': 'ignore'} }, row_width=1)) -@bot.message_handler(content_types = ['document', 'file'], func = lambda m: m.from_user.id in config.ADMINS) +@bot.message_handler( + content_types=['document', 'file'], + func=lambda message: message.from_user.id in config.ADMINS + and bool(message.document) + and (message.document.file_name or '').lower().endswith('.json'), +) def massSendHandle(message: Message): global REMEMBER_MESSAGE_DATA, REMEMBER_JSON_DATA, REMEMBER_PHOTO_ID REMEMBER_PHOTO_ID = False