From 4725ddbcf42aaa16b76da9bd19a73d7b25799d42 Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Fri, 26 Dec 2025 13:44:11 +0500 Subject: [PATCH 01/15] app: make default admin panel in bot --- app/config.py | 129 ++--- app/const.py | 1 - app/database.py | 33 +- app/main.py | 46 +- app/models.py | 104 ++-- app/telebot/bot.py | 13 +- app/telebot/handlers.py | 1034 +++++++++++++++++++++++++++++++++------ app/utils.py | 6 +- config.yaml | 12 +- 9 files changed, 1045 insertions(+), 333 deletions(-) diff --git a/app/config.py b/app/config.py index 6d86215..09d5adb 100644 --- a/app/config.py +++ b/app/config.py @@ -1,124 +1,62 @@ -import os -import stat -from datetime import datetime from functools import lru_cache +import json from typing import Any, List -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import yaml from pydantic import BaseSettings, Field, validator -from .utils import config_path, parse_admin_ids - - -def detect_default_timezone() -> str: - """Return the host timezone name or UTC if it cannot be determined.""" - - try: - tzinfo = datetime.now().astimezone().tzinfo - if tzinfo is None: - return "UTC" - - key = getattr(tzinfo, "key", None) - if key: - return key - - name = tzinfo.tzname(None) - if name: - try: - ZoneInfo(name) - except ZoneInfoNotFoundError: - pass - else: - return name - except Exception: - pass - - return "UTC" +from .utils import config_path, parse_admin_usernames class Settings(BaseSettings): telegram_token: str = Field(..., env="TELEGRAM_TOKEN") - admin_ids: List[int] = Field(default_factory=list, env="ADMIN_IDS") + admin_usernames: List[str] = Field(default_factory=list, env="ADMIN_USERNAMES") database_url: str = Field( default="sqlite:///./anonchatbot.db", env="DATABASE_URL", description="SQLAlchemy compatible database URL.", ) - event_name: str = Field(default="Community Event", env="EVENT_NAME") - attendee_limit: int = Field(default=150, env="ATTENDEE_LIMIT") - locale: str = Field(default="en", env="LOCALE") - - web_host: str = Field(default="0.0.0.0", env="WEB_HOST") - web_port: int = Field(default=8000, env="WEB_PORT") - - basic_auth_username: str = Field(..., env="ADMIN_USERNAME") - basic_auth_password: str = Field(..., env="ADMIN_PASSWORD") - - scheduler_interval_seconds: int = Field(default=60, env="SCHEDULER_INTERVAL_SECONDS") - timezone: str = Field(default_factory=detect_default_timezone, env="TIMEZONE") class Config: env_file = ".env" env_file_encoding = "utf-8" case_sensitive = False - @validator("admin_ids", pre=True) - def validate_admin_ids(cls, value: str | List[int] | None) -> List[int]: # type: ignore[override] - return parse_admin_ids(value) - - @classmethod - def _parse_admin_ids(cls, value: str | List[int] | None) -> List[int]: - return parse_admin_ids(value) - - @property - def admin_id_set(self) -> set[int]: - return set(self.admin_ids) - - @property - def tzinfo(self) -> ZoneInfo: - try: - return ZoneInfo(self.timezone) - except ZoneInfoNotFoundError: - return ZoneInfo("UTC") + @classmethod + def customise_sources(cls, init_settings, env_settings, file_secret_settings): + return ( + init_settings, + env_settings, + cls._yaml_settings_source, + file_secret_settings, + ) - @property - def can_persist_timezone(self) -> bool: - return config_path() is not None + @classmethod + def _yaml_settings_source(cls, settings: BaseSettings) -> dict[str, Any]: + return settings.__class__.load_from_yaml() - def set_timezone(self, timezone_name: str) -> bool: - try: - ZoneInfo(timezone_name) - except ZoneInfoNotFoundError as exc: - raise ValueError(f"Unknown timezone '{timezone_name}'") from exc + @classmethod + def parse_env_var(cls, field_name: str, raw_val: str) -> Any: + if field_name == "admin_usernames": + try: + loaded = json.loads(raw_val) + except json.JSONDecodeError: + return Settings._parse_admin_usernames(raw_val) + return Settings._parse_admin_usernames(loaded) + return json.loads(raw_val) - object.__setattr__(self, "timezone", timezone_name) - return self._persist_value("timezone", timezone_name) + @validator("admin_usernames", pre=True) + def validate_admin_usernames(cls, value: str | List[str] | None) -> List[str]: # type: ignore[override] + return parse_admin_usernames(value) - def _persist_value(self, key: str, value: Any) -> bool: - path = config_path() - if not path: - return False - - data = self.load_from_yaml() - data[key] = value + @classmethod + def _parse_admin_usernames(cls, value: str | List[str] | None) -> List[str]: + return parse_admin_usernames(value) - if path.exists(): - permissions = stat.S_IMODE(path.stat().st_mode) - writable_mask = stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH - if permissions & writable_mask == 0: - return False - if not os.access(path, os.W_OK): - return False - try: - path.write_text( - yaml.safe_dump(data, sort_keys=True, allow_unicode=True), - encoding="utf-8", - ) - except OSError: - return False - return True + @property + def admin_username_set(self) -> set[str]: + return {name.lower() for name in self.admin_usernames} @classmethod def load_from_yaml(cls) -> dict[str, Any]: @@ -131,5 +69,4 @@ def load_from_yaml(cls) -> dict[str, Any]: @lru_cache(None) def get_settings() -> Settings: - yaml_values = Settings.load_from_yaml() - return Settings(**yaml_values) + return Settings() diff --git a/app/const.py b/app/const.py index 94b029b..60253c2 100644 --- a/app/const.py +++ b/app/const.py @@ -9,4 +9,3 @@ Path("/config"), Path("/app"), ) - diff --git a/app/database.py b/app/database.py index 9bf0209..3d3cbdd 100644 --- a/app/database.py +++ b/app/database.py @@ -2,13 +2,12 @@ from pathlib import Path from typing import Iterator -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlalchemy.engine import make_url from sqlalchemy.orm import Session, declarative_base, sessionmaker from app.config import get_settings - settings = get_settings() connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} @@ -25,6 +24,36 @@ Base = declarative_base() +SCHEMA_VERSION = 2 + + +def ensure_schema() -> None: + with engine.begin() as connection: + connection.execute( + text( + "CREATE TABLE IF NOT EXISTS schema_version (" + "id INTEGER PRIMARY KEY, " + "version INTEGER NOT NULL)" + ) + ) + result = connection.execute(text("SELECT version FROM schema_version LIMIT 1")) + current_version = result.scalar() + if current_version is None: + connection.execute(text("INSERT INTO schema_version (version) VALUES (0)")) + current_version = 0 + + if current_version < SCHEMA_VERSION: + Base.metadata.drop_all(bind=engine) + from app import models # noqa: F401 + + Base.metadata.create_all(bind=engine) + with engine.begin() as connection: + connection.execute(text("DELETE FROM schema_version")) + connection.execute( + text("INSERT INTO schema_version (version) VALUES (:version)"), + {"version": SCHEMA_VERSION}, + ) + @contextmanager def session_scope() -> Iterator[Session]: diff --git a/app/main.py b/app/main.py index 17b555d..0500d77 100644 --- a/app/main.py +++ b/app/main.py @@ -1,32 +1,33 @@ import asyncio -import uvicorn +import logging +import os from app.config import get_settings -from app.database import Base, engine -from app.scheduler import start_scheduler, stop_scheduler +from app.database import ensure_schema from app.telebot.bot import build_application -from app.web.admin import create_app +from app.utils import config_path -async def run() -> None: - settings = get_settings() - - Base.metadata.create_all(bind=engine) - - from app.database import session_scope - from app.services.events import get_or_create_default_event +def configure_logging() -> None: + level_name = os.getenv("LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + ) - with session_scope() as session: - get_or_create_default_event(session, settings) - application = build_application(settings) - scheduler = start_scheduler(settings, application.bot) - admin_app = create_app(settings, bot=application.bot) - config = uvicorn.Config( - admin_app, host=settings.web_host, port=settings.web_port, log_level="info" - ) - server = uvicorn.Server(config) +async def run() -> None: + configure_logging() + logger = logging.getLogger(__name__) + settings = get_settings() + logger.info("Config file: %s", config_path() or "not found") + logger.info("Admin usernames loaded: %s", settings.admin_usernames) + logger.info("Database URL: %s", settings.database_url) + ensure_schema() + application = build_application() + logger.info("Telegram application initialized; starting polling.") await application.initialize() if application.post_init: await application.post_init(application) @@ -34,9 +35,8 @@ async def run() -> None: await application.start() try: - await server.serve() + await asyncio.Event().wait() finally: - stop_scheduler(scheduler) if application.updater.running: await application.updater.stop() if application.running: @@ -46,8 +46,6 @@ async def run() -> None: await application.shutdown() if application.post_shutdown: await application.post_shutdown(application) - if not server.should_exit: - server.should_exit = True def main() -> None: diff --git a/app/models.py b/app/models.py index 1ac2f31..85a0a57 100644 --- a/app/models.py +++ b/app/models.py @@ -1,80 +1,94 @@ import enum from datetime import datetime -from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String, Text +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + DateTime, + Enum, + ForeignKey, + Integer, + String, + Text, +) from sqlalchemy.orm import relationship from app.database import Base -class RegistrationCategory(str, enum.Enum): - ATTENDEE = "attendee" - LECTURER = "lecturer" - SHOWCASE = "showcase" +class UserStatus(str, enum.Enum): + NONE = "NONE" + PROCESSING = "PROCESSING" + ATTENDEE = "ATTENDEE" + WAITLIST = "WAITLIST" -class RegistrationStatus(str, enum.Enum): - PENDING = "pending" - APPROVED = "approved" - WAITLISTED = "waitlisted" - REJECTED = "rejected" +class AdminStateType(str, enum.Enum): + WELCOME = "WELCOME" + SCHEDULE = "SCHEDULE" + BROADCAST_ALL = "BROADCAST_ALL" + BROADCAST_ATTENDEE = "BROADCAST_ATTENDEE" + UPLOAD_DB = "UPLOAD_DB" class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) - telegram_id = Column(Integer, unique=True, nullable=True) + telegram_id = Column(BigInteger, unique=True, nullable=False) username = Column(String(255), nullable=True) - first_name = Column(String(255), nullable=True) - last_name = Column(String(255), nullable=True) - display_name = Column(String(255), nullable=False) - contact = Column(String(255), nullable=True) - is_manual = Column(Boolean, default=False, nullable=False) - is_subscribed = Column(Boolean, default=True, nullable=False) + full_name = Column(String(255), nullable=True) + job = Column(String(255), nullable=True) + career_path = Column(String(255), nullable=True) + status = Column(Enum(UserStatus), default=UserStatus.NONE, nullable=False) + notifications_enabled = Column(Boolean, default=True, nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - registrations = relationship( - "Registration", back_populates="user", cascade="all, delete-orphan" - ) + feedback = relationship("Feedback", back_populates="user", cascade="all, delete-orphan") -class Event(Base): - __tablename__ = "events" +class Feedback(Base): + __tablename__ = "feedback" id = Column(Integer, primary_key=True) - name = Column(String(255), unique=True, nullable=False) - capacity = Column(Integer, nullable=True) + event_id = Column(String(255), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + feedback_text = Column(Text, nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - registrations = relationship( - "Registration", back_populates="event", cascade="all, delete-orphan" - ) + user = relationship("User", back_populates="feedback") + +class MessageTemplate(Base): + __tablename__ = "message_templates" -class Registration(Base): - __tablename__ = "registrations" + name = Column(String(255), primary_key=True) + admin_chat_id = Column(BigInteger, nullable=False) + message_id = Column(Integer, nullable=False) + + +class AdminState(Base): + __tablename__ = "admin_state" id = Column(Integer, primary_key=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - event_id = Column(Integer, ForeignKey("events.id"), nullable=False) - category = Column(Enum(RegistrationCategory), nullable=False) - status = Column(Enum(RegistrationStatus), default=RegistrationStatus.PENDING, nullable=False) - is_priority = Column(Boolean, default=False, nullable=False) - notes = Column(Text, nullable=True) + admin_id = Column(BigInteger, nullable=False, index=True) + waiting_for = Column(Enum(AdminStateType), nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + ttl_seconds = Column(Integer, default=300, nullable=False) + - user = relationship("User", back_populates="registrations") - event = relationship("Event", back_populates="registrations") +class EventState(Base): + __tablename__ = "event_state" + id = Column(Integer, primary_key=True) + event_started = Column(Boolean, default=False, nullable=False) + current_event_id = Column(String(255), nullable=True) -class ScheduledPost(Base): - __tablename__ = "scheduled_posts" + +class SchemaVersion(Base): + __tablename__ = "schema_version" id = Column(Integer, primary_key=True) - title = Column(String(255), nullable=False) - content = Column(Text, nullable=False) - send_at = Column(DateTime, nullable=False) - sent_at = Column(DateTime, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + version = Column(Integer, nullable=False) diff --git a/app/telebot/bot.py b/app/telebot/bot.py index 1d2c374..5b31c9a 100644 --- a/app/telebot/bot.py +++ b/app/telebot/bot.py @@ -1,10 +1,17 @@ -from telegram.ext import ApplicationBuilder, Application +import logging -from app.config import Settings +from telegram.ext import Application, ApplicationBuilder + +from app.config import get_settings from app.telebot import handlers -def build_application(settings: Settings) -> Application: +logger = logging.getLogger(__name__) + + +def build_application() -> Application: + settings = get_settings() + logger.info("Building Telegram application. Token configured: %s", bool(settings.telegram_token)) application = ApplicationBuilder().token(settings.telegram_token).build() handlers.register(application) return application diff --git a/app/telebot/handlers.py b/app/telebot/handlers.py index 43f317d..fc246b0 100644 --- a/app/telebot/handlers.py +++ b/app/telebot/handlers.py @@ -1,62 +1,189 @@ -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update -from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes -from sqlalchemy import select +import asyncio +import csv +import io +import logging +from datetime import datetime, timedelta -from app.config import Settings, get_settings +from sqlalchemy import select +from telegram import ReplyKeyboardMarkup, Update +from telegram.ext import ( + Application, + CommandHandler, + ContextTypes, + ConversationHandler, + MessageHandler, + filters, +) + +from app.config import get_settings from app.database import session_scope -from app.localization import get_localizer -from app.models import Registration, RegistrationCategory, RegistrationStatus -from app.services.events import get_or_create_default_event -from app.services.registrations import register_user -from app.services.users import get_or_create_user +from app.models import ( + AdminState, + AdminStateType, + EventState, + Feedback, + MessageTemplate, + User, + UserStatus, +) -REGISTRATION_CALLBACK_PREFIX = "register:" +logger = logging.getLogger(__name__) -def build_registration_keyboard(localizer) -> InlineKeyboardMarkup: - buttons = [ - [ - InlineKeyboardButton( - text=localizer.get("buttons.attend"), - callback_data=( - f"{REGISTRATION_CALLBACK_PREFIX}{RegistrationCategory.ATTENDEE.value}" - ), - ) - ], - [ - InlineKeyboardButton( - text=localizer.get("buttons.lecturer"), - callback_data=( - f"{REGISTRATION_CALLBACK_PREFIX}{RegistrationCategory.LECTURER.value}" - ), - ) - ], - [ - InlineKeyboardButton( - text=localizer.get("buttons.showcase"), - callback_data=( - f"{REGISTRATION_CALLBACK_PREFIX}{RegistrationCategory.SHOWCASE.value}" - ), - ) - ], +MENU_APPLICATION = "Заявка" +MENU_CANCEL = "Отмена заявки" +MENU_FEEDBACK = "Отзыв" +MENU_SCHEDULE = "Афиша" +MENU_STATUS = "Статус" +MENU_NOTIFICATIONS = "Нотификации" +MENU_HOME = "На главную" + +APPLICATION_FULL_NAME = 1 +APPLICATION_JOB = 2 +APPLICATION_CAREER = 3 + +FEEDBACK_TEXT = 10 + + +def is_admin(username: str | None) -> bool: + if not username: + return False + settings = get_settings() + return username.lstrip("@").lower() in settings.admin_username_set + + +def build_main_keyboard(status: UserStatus, event_started: bool) -> ReplyKeyboardMarkup: + if event_started and status == UserStatus.ATTENDEE: + first_button = MENU_FEEDBACK + elif status == UserStatus.NONE: + first_button = MENU_APPLICATION + else: + first_button = MENU_CANCEL + keyboard = [ + [first_button, MENU_SCHEDULE], + [MENU_STATUS, MENU_NOTIFICATIONS], ] - return InlineKeyboardMarkup(buttons) + return ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + + +def home_keyboard() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup([[MENU_HOME]], resize_keyboard=True) + + +def notifications_text(enabled: bool) -> str: + status = "Включены" if enabled else "Выключены" + return ( + f"[{status}] Бот будет присылать важные уведомления, когда админы их отправят " + "(в т.ч. можно заранее запланировать в Telegram). Рекомендуем оставить " + "включенными. Отключение: /notifications_disable, включение: /notifications_enable." + ) + + +def status_text(status: UserStatus) -> str: + mapping = { + UserStatus.NONE: "Нет заявки", + UserStatus.PROCESSING: "Заявка в обработке", + UserStatus.ATTENDEE: "Участник", + UserStatus.WAITLIST: "Лист ожидания", + } + return mapping[status] + + +def get_or_create_event_state(session) -> EventState: + state = session.scalar(select(EventState).limit(1)) + if state: + return state + state = EventState(event_started=False, current_event_id="default") + session.add(state) + session.flush() + return state + + +def upsert_user(session, tg_user) -> tuple[User, bool]: + user = session.scalar(select(User).where(User.telegram_id == tg_user.id)) + is_new = False + if not user: + user = User( + telegram_id=tg_user.id, + username=tg_user.username, + status=UserStatus.NONE, + notifications_enabled=True, + created_at=datetime.utcnow(), + ) + session.add(user) + session.flush() + is_new = True + user.username = tg_user.username + user.updated_at = datetime.utcnow() + return user, is_new + + +def get_template(session, name: str) -> MessageTemplate | None: + return session.scalar(select(MessageTemplate).where(MessageTemplate.name == name)) + + +def set_template(session, name: str, chat_id: int, message_id: int) -> None: + template = get_template(session, name) + if template: + template.admin_chat_id = chat_id + template.message_id = message_id + return + template = MessageTemplate(name=name, admin_chat_id=chat_id, message_id=message_id) + session.add(template) + + +def set_admin_state( + session, admin_id: int, waiting_for: AdminStateType, ttl_seconds: int = 300 +) -> None: + session.query(AdminState).where(AdminState.admin_id == admin_id).delete() + state = AdminState( + admin_id=admin_id, + waiting_for=waiting_for, + ttl_seconds=ttl_seconds, + created_at=datetime.utcnow(), + ) + session.add(state) -def _localized_status(localizer, registration: Registration) -> str: - base = localizer.get(f"registration.status.{registration.status.value}") - if registration.status == RegistrationStatus.WAITLISTED: - suffix = localizer.get("registration.status.waitlisted_suffix") - return f"{base}{suffix}" - if registration.status == RegistrationStatus.APPROVED: - suffix = localizer.get("registration.status.approved_suffix") - return f"{base}{suffix}" - return base +def clear_admin_state(session, admin_id: int) -> None: + session.query(AdminState).where(AdminState.admin_id == admin_id).delete() -def _localized_category(localizer, category: RegistrationCategory) -> str: - return localizer.get(f"registration.category.{category.value}") +def get_admin_state(session, admin_id: int) -> AdminState | None: + state = session.scalar(select(AdminState).where(AdminState.admin_id == admin_id)) + if not state: + return None + if datetime.utcnow() > state.created_at + timedelta(seconds=state.ttl_seconds): + session.delete(state) + return None + return state + + +async def send_welcome_message(update: Update, template: MessageTemplate | None) -> None: + if not update.effective_chat: + return + if not template: + await update.effective_chat.send_message("No message template: welcome_message") + return + await update.effective_chat.bot.copy_message( + chat_id=update.effective_chat.id, + from_chat_id=template.admin_chat_id, + message_id=template.message_id, + ) + + +async def send_schedule_message(update: Update, template: MessageTemplate | None) -> None: + if not update.effective_chat: + return + if not template: + await update.effective_chat.send_message("No message template: schedule_message") + return + await update.effective_chat.bot.copy_message( + chat_id=update.effective_chat.id, + from_chat_id=template.admin_chat_id, + message_id=template.message_id, + ) async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -65,149 +192,758 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not user or not chat: return - settings = get_settings() - with session_scope() as session: - db_user = get_or_create_user(session, user) - event = get_or_create_default_event(session, settings) - registrations = ( - session.execute( - select(Registration) - .where(Registration.user_id == db_user.id) - .where(Registration.event_id == event.id) - ) - .scalars() - .all() - ) + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + template = get_template(session, "welcome_message") - localizer = get_localizer(settings.locale) - - if registrations: - lines: list[str] = [] - for registration in registrations: - status_text = _localized_status(localizer, registration) - category_text = _localized_category(localizer, registration.category) - lines.append( - localizer.format( - "start.summary_item", category=category_text, status=status_text - ) - ) - summary = "\n".join(lines) - welcome_text = localizer.format( - "start.returning", - name=user.first_name or user.full_name or user.username, - summary=summary, - ) - else: - welcome_text = localizer.format( - "start.new", - name=user.first_name or user.full_name or user.username, - event_name=settings.event_name, - ) + await send_welcome_message(update, template) await context.bot.send_message( chat_id=chat.id, - text=welcome_text, - reply_markup=build_registration_keyboard(localizer), + text="Главное меню", + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), ) -async def handle_registration_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - query = update.callback_query - if not query: +async def show_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: return - await query.answer() + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + message = status_text(db_user.status) + await context.bot.send_message( + chat_id=chat.id, + text=message, + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + - data = query.data or "" - if not data.startswith(REGISTRATION_CALLBACK_PREFIX): +async def show_notifications(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: return + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + message = notifications_text(db_user.notifications_enabled) + await context.bot.send_message( + chat_id=chat.id, + text=message, + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) - category_value = data.split(":", 1)[1] - settings: Settings = get_settings() - localizer = get_localizer(settings.locale) - try: - category = RegistrationCategory(category_value) - except ValueError: - await query.edit_message_text(localizer.get("registration.callback.unknown_option")) +async def notifications_disable(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + if not user or not update.effective_chat: return + with session_scope() as session: + db_user, _ = upsert_user(session, user) + db_user.notifications_enabled = False + event_state = get_or_create_event_state(session) + message = notifications_text(db_user.notifications_enabled) + await context.bot.send_message( + chat_id=update.effective_chat.id, + text=message, + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) - user = query.from_user - if not user: + +async def notifications_enable(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + if not user or not update.effective_chat: return + with session_scope() as session: + db_user, _ = upsert_user(session, user) + db_user.notifications_enabled = True + event_state = get_or_create_event_state(session) + message = notifications_text(db_user.notifications_enabled) + await context.bot.send_message( + chat_id=update.effective_chat.id, + text=message, + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + +async def application_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + if not user or not update.effective_chat: + return ConversationHandler.END with session_scope() as session: - db_user = get_or_create_user(session, user) - event = get_or_create_default_event(session, settings) - result = register_user(session, event=event, user=db_user, category=category) + db_user, _ = upsert_user(session, user) + if db_user.status != UserStatus.NONE: + await update.effective_chat.send_message( + "Заявка уже создана.", + reply_markup=home_keyboard(), + ) + return ConversationHandler.END + await update.effective_chat.send_message( + "Как вас зовут? (Имя Фамилия)", + reply_markup=home_keyboard(), + ) + return APPLICATION_FULL_NAME - if result.created: - if category == RegistrationCategory.ATTENDEE: - if result.waitlisted: - message = localizer.get("registration.callback.waitlisted") - else: - message = localizer.get("registration.callback.attendee") - elif category == RegistrationCategory.LECTURER: - message = localizer.get("registration.callback.lecturer") - else: - message = localizer.get("registration.callback.showcase") - else: - message = localizer.get("registration.callback.duplicate") - await query.edit_message_text(message) +async def application_full_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not update.message or not update.message.text: + return APPLICATION_FULL_NAME + context.user_data["full_name"] = update.message.text.strip() + await update.message.reply_text("Кем и где вы работаете? (позиция, компания)") + return APPLICATION_JOB + + +async def application_job(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not update.message or not update.message.text: + return APPLICATION_JOB + context.user_data["job"] = update.message.text.strip() + await update.message.reply_text("Какой путь... (1)... (2)... В ответ напишите цифру") + return APPLICATION_CAREER + + +async def application_career(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not update.message or not update.message.text: + return APPLICATION_CAREER + career_path = update.message.text.strip() + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return ConversationHandler.END + with session_scope() as session: + db_user, _ = upsert_user(session, user) + db_user.full_name = context.user_data.get("full_name") + db_user.job = context.user_data.get("job") + db_user.career_path = career_path + db_user.status = UserStatus.PROCESSING + event_state = get_or_create_event_state(session) + await chat.send_message( + "Спасибо за вашу заявку! Ожидайте новостей по мероприятию.", + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + context.user_data.clear() + return ConversationHandler.END + + +async def application_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return ConversationHandler.END + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + context.user_data.clear() + await chat.send_message( + "Главное меню", + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + return ConversationHandler.END -async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: +async def cancel_application(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: user = update.effective_user chat = update.effective_chat if not user or not chat: return + with session_scope() as session: + db_user, _ = upsert_user(session, user) + db_user.status = UserStatus.NONE + db_user.full_name = None + db_user.job = None + db_user.career_path = None + event_state = get_or_create_event_state(session) + await chat.send_message( + "Заявка отменена.", + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + + +async def schedule(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + template = get_template(session, "schedule_message") + await send_schedule_message(update, template) + await context.bot.send_message( + chat_id=chat.id, + text="Главное меню", + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + + +async def feedback_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return ConversationHandler.END + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + if not (event_state.event_started and db_user.status == UserStatus.ATTENDEE): + await chat.send_message( + "Главное меню", + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + return ConversationHandler.END + await chat.send_message( + "Вы можете оставить отзыв на последнее мероприятие, в котором принимали участие. " + "Отправьте одно сообщение в свободной форме. Мы будем рады развернутому отзыву " + "по ссылке: <ссылка>", + reply_markup=home_keyboard(), + ) + return FEEDBACK_TEXT + + +async def feedback_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not update.message or not update.message.text: + return FEEDBACK_TEXT + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return ConversationHandler.END + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + feedback = Feedback( + event_id=event_state.current_event_id or "default", + user_id=db_user.id, + feedback_text=update.message.text.strip(), + created_at=datetime.utcnow(), + ) + session.add(feedback) + await chat.send_message( + "Спасибо за ваш отзыв!", + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + return ConversationHandler.END + + +async def feedback_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + return await application_cancel(update, context) - settings = get_settings() - localizer = get_localizer(settings.locale) +async def send_attendee_notification(bot, telegram_id: int) -> None: + await asyncio.sleep(30) with session_scope() as session: - db_user = get_or_create_user(session, user) - event = get_or_create_default_event(session, settings) - registrations = ( + user = session.scalar(select(User).where(User.telegram_id == telegram_id)) + if not user or user.status != UserStatus.ATTENDEE: + return + await bot.send_message( + chat_id=telegram_id, + text=( + "Статус обновлен: участник. Будем рады видеть вас на мероприятии. " + "Подробнее можно узнать в Афише." + ), + ) + + +async def handle_admin_payload(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + if not user or not update.message or not is_admin(user.username): + return + with session_scope() as session: + state = get_admin_state(session, user.id) + if not state: + return + if state.waiting_for == AdminStateType.UPLOAD_DB: + if not update.message.document: + await update.message.reply_text("Ожидается CSV-файл.") + return + await process_upload_database(update, context, state.admin_id) + return + message = update.message + if state.waiting_for == AdminStateType.WELCOME: + set_template(session, "welcome_message", message.chat_id, message.message_id) + clear_admin_state(session, user.id) + await update.message.reply_text("welcome_message сохранено.") + return + if state.waiting_for == AdminStateType.SCHEDULE: + set_template(session, "schedule_message", message.chat_id, message.message_id) + clear_admin_state(session, user.id) + await update.message.reply_text("schedule_message сохранено.") + return + if state.waiting_for in (AdminStateType.BROADCAST_ALL, AdminStateType.BROADCAST_ATTENDEE): + if message.text and message.text.startswith("/"): + await update.message.reply_text( + "Waiting for the notification message" + ) + return + clear_admin_state(session, user.id) + await broadcast_payload(session, context, message, state.waiting_for) + return + + +async def log_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + chat = update.effective_chat + message = update.message + if not (user or chat or message): + logger.info("Received update without user/chat/message (update_id=%s)", update.update_id) + return + logger.info( + "Update received: update_id=%s user_id=%s username=%s chat_id=%s has_message=%s has_text=%s", + update.update_id, + user.id if user else None, + user.username if user else None, + chat.id if chat else None, + bool(message), + bool(message and message.text), + ) + + +async def broadcast_payload(session, context, message, waiting_for: AdminStateType) -> None: + if waiting_for == AdminStateType.BROADCAST_ATTENDEE: + users = ( session.execute( - select(Registration) - .where(Registration.user_id == db_user.id) - .where(Registration.event_id == event.id) + select(User).where( + User.status == UserStatus.ATTENDEE, + User.notifications_enabled.is_(True), + ) ) .scalars() .all() ) - - if not registrations: - await context.bot.send_message( - chat_id=chat.id, - text=localizer.get("status.none_found"), + else: + users = ( + session.execute(select(User).where(User.notifications_enabled.is_(True))) + .scalars() + .all() ) + for target in users: + try: + await context.bot.copy_message( + chat_id=target.telegram_id, + from_chat_id=message.chat_id, + message_id=message.message_id, + ) + except Exception: + logger.exception("Failed to send broadcast to %s", target.telegram_id) + + +async def process_upload_database( + update: Update, context: ContextTypes.DEFAULT_TYPE, admin_id: int +) -> None: + if not update.message or not update.message.document: + return + file = await context.bot.get_file(update.message.document.file_id) + content = await file.download_as_bytearray() + stream = io.StringIO(content.decode("utf-8")) + reader = csv.DictReader(stream) + with session_scope() as session: + clear_admin_state(session, admin_id) + for row in reader: + if not row.get("user_id"): + continue + telegram_id = int(row["user_id"]) + user = session.scalar(select(User).where(User.telegram_id == telegram_id)) + if not user: + user = User( + telegram_id=telegram_id, + notifications_enabled=True, + status=UserStatus.NONE, + created_at=datetime.utcnow(), + ) + session.add(user) + session.flush() + user.username = row.get("username") or user.username + user.full_name = row.get("full_name") or None + user.job = row.get("job") or None + user.career_path = row.get("career_path") or None + status_value = row.get("status") + if status_value and status_value in UserStatus.__members__: + user.status = UserStatus[status_value] + if row.get("notifications_enabled") is not None: + user.notifications_enabled = row["notifications_enabled"].lower() == "true" + user.updated_at = datetime.utcnow() + await update.message.reply_text("База данных обновлена.") + + +def ensure_admin(update: Update) -> bool: + user = update.effective_user + if not user or not is_admin(user.username): + if update.effective_chat: + asyncio.create_task( + update.effective_chat.send_message("Unknown command or has no permission") + ) + return False + return True + + +async def admin_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): return + commands = [ + "/admin", + "/download_database", + "/upload_database", + "/check_applications", + "/approve {nickname}", + "/disapprove {nickname}", + "/processing {nickname}", + "/approve_id {user_id}", + "/disapprove_id {user_id}", + "/processing_id {user_id}", + "/set_welcome_message", + "/set_schedule_message", + "/urgent_notification", + "/urgent_notification_attendee", + "/event_start", + "/event_cancel", + "/set_event_id {id}", + ] + await update.effective_chat.send_message("Команды:\n" + "\n".join(commands)) - parts = [] - for registration in registrations: - status_text = _localized_status(localizer, registration) - category_text = _localized_category(localizer, registration.category) - parts.append(localizer.format("status.line", category=category_text, status=status_text)) - await context.bot.send_message(chat_id=chat.id, text="\n".join(parts)) +async def set_welcome_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + set_admin_state(session, user.id, AdminStateType.WELCOME) + await update.effective_chat.send_message("Ожидаю следующее сообщение для welcome_message.") -async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - chat = update.effective_chat - if not chat: +async def set_schedule_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): return - settings = get_settings() - localizer = get_localizer(settings.locale) - await context.bot.send_message( - chat_id=chat.id, - text=localizer.get("help.text"), + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + set_admin_state(session, user.id, AdminStateType.SCHEDULE) + await update.effective_chat.send_message("Ожидаю следующее сообщение для schedule_message.") + + +async def urgent_notification(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + set_admin_state(session, user.id, AdminStateType.BROADCAST_ALL) + await update.effective_chat.send_message("Ожидаю следующее сообщение для рассылки всем.") + + +async def urgent_notification_attendee(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + set_admin_state(session, user.id, AdminStateType.BROADCAST_ATTENDEE) + await update.effective_chat.send_message("Ожидаю следующее сообщение для рассылки участникам.") + + +async def upload_database(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + set_admin_state(session, user.id, AdminStateType.UPLOAD_DB) + await update.effective_chat.send_message("Ожидаю CSV-файл с пользователями.") + + +async def download_database(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + if not update.effective_chat: + return + with session_scope() as session: + users = session.execute(select(User)).scalars().all() + feedback = session.execute(select(Feedback)).scalars().all() + user_stream = io.StringIO() + user_writer = csv.writer(user_stream) + user_writer.writerow( + [ + "user_id", + "username", + "full_name", + "job", + "career_path", + "status", + "notifications_enabled", + "created_at", + "updated_at", + ] ) + for user in users: + user_writer.writerow( + [ + user.telegram_id, + user.username or "", + user.full_name or "", + user.job or "", + user.career_path or "", + user.status.value, + str(user.notifications_enabled).lower(), + user.created_at.isoformat(), + user.updated_at.isoformat() if user.updated_at else "", + ] + ) + user_stream.seek(0) + await update.effective_chat.send_document( + document=io.BytesIO(user_stream.getvalue().encode("utf-8")), + filename="users.csv", + ) + + feedback_stream = io.StringIO() + feedback_writer = csv.writer(feedback_stream) + feedback_writer.writerow(["event_id", "user_id", "feedback_text", "created_at"]) + for item in feedback: + feedback_writer.writerow( + [ + item.event_id, + item.user.telegram_id if item.user else "", + item.feedback_text, + item.created_at.isoformat(), + ] + ) + feedback_stream.seek(0) + await update.effective_chat.send_document( + document=io.BytesIO(feedback_stream.getvalue().encode("utf-8")), + filename="feedback.csv", + ) + + +async def check_applications(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + if not update.effective_chat: + return + with session_scope() as session: + users = session.execute(select(User)).scalars().all() + applications = [user for user in users if user.status != UserStatus.NONE] + attendee_count = len([user for user in users if user.status == UserStatus.ATTENDEE]) + lines = [f"applications: {len(applications)} | attendee: {attendee_count}"] + for user in applications: + label = f"@{user.username}" if user.username else str(user.telegram_id) + lines.append(f"{label} -> {user.status.value}") + await update.effective_chat.send_message("\n".join(lines)) + + +def parse_username(text: str | None) -> str | None: + if not text: + return None + return text.lstrip("@").strip() + + +async def update_status_by_username( + update: Update, context: ContextTypes.DEFAULT_TYPE, status: UserStatus +) -> None: + if not ensure_admin(update): + return + if not update.effective_chat or not update.message: + return + parts = update.message.text.split(maxsplit=1) + if len(parts) < 2: + await update.effective_chat.send_message("Нужен nickname.") + return + nickname = parse_username(parts[1]) + with session_scope() as session: + user = session.scalar(select(User).where(User.username.ilike(nickname))) + if not user: + await update.effective_chat.send_message("Пользователь не найден.") + return + user.status = status + user.updated_at = datetime.utcnow() + if status == UserStatus.ATTENDEE: + asyncio.create_task(send_attendee_notification(context.bot, user.telegram_id)) + await update.effective_chat.send_message("Статус обновлен.") + + +async def update_status_by_id( + update: Update, context: ContextTypes.DEFAULT_TYPE, status: UserStatus +) -> None: + if not ensure_admin(update): + return + if not update.effective_chat or not update.message: + return + parts = update.message.text.split(maxsplit=1) + if len(parts) < 2: + await update.effective_chat.send_message("Нужен user_id.") + return + try: + user_id = int(parts[1]) + except ValueError: + await update.effective_chat.send_message("Неверный user_id.") + return + with session_scope() as session: + user = session.scalar(select(User).where(User.telegram_id == user_id)) + if not user: + await update.effective_chat.send_message("Пользователь не найден.") + return + user.status = status + user.updated_at = datetime.utcnow() + if status == UserStatus.ATTENDEE: + asyncio.create_task(send_attendee_notification(context.bot, user.telegram_id)) + await update.effective_chat.send_message("Статус обновлен.") + + +async def event_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + with session_scope() as session: + state = get_or_create_event_state(session) + state.event_started = True + await update.effective_chat.send_message("event_started=true") + + +async def event_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + with session_scope() as session: + state = get_or_create_event_state(session) + state.event_started = False + await update.effective_chat.send_message("event_started=false") + + +async def set_event_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + if not update.message or not update.effective_chat: + return + parts = update.message.text.split(maxsplit=1) + if len(parts) < 2: + await update.effective_chat.send_message("Нужен id.") + return + with session_scope() as session: + state = get_or_create_event_state(session) + state.current_event_id = parts[1] + await update.effective_chat.send_message("event_id обновлен.") + + +async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + if not user or not update.effective_chat: + return + if not is_admin(user.username): + await update.effective_chat.send_message("Unknown command or has no permission") + else: + await update.effective_chat.send_message("Unknown command") def register(application: Application) -> None: + application.add_handler(MessageHandler(filters.ALL, handle_admin_payload), group=1) + application.add_handler(MessageHandler(filters.ALL, log_update), group=2) + application.add_handler(CommandHandler("start", start)) - application.add_handler(CommandHandler("help", help_command)) - application.add_handler(CommandHandler("status", status)) - application.add_handler(CallbackQueryHandler(handle_registration_callback)) + application.add_handler(CommandHandler("notifications_disable", notifications_disable)) + application.add_handler(CommandHandler("notifications_enable", notifications_enable)) + + application.add_handler(CommandHandler("admin", admin_help)) + application.add_handler(CommandHandler("download_database", download_database)) + application.add_handler(CommandHandler("upload_database", upload_database)) + application.add_handler(CommandHandler("check_applications", check_applications)) + application.add_handler(CommandHandler("set_welcome_message", set_welcome_message)) + application.add_handler(CommandHandler("set_schedule_message", set_schedule_message)) + application.add_handler(CommandHandler("urgent_notification", urgent_notification)) + application.add_handler( + CommandHandler("urgent_notification_attendee", urgent_notification_attendee) + ) + application.add_handler(CommandHandler("event_start", event_start)) + application.add_handler(CommandHandler("event_cancel", event_cancel)) + application.add_handler(CommandHandler("set_event_id", set_event_id)) + application.add_handler( + CommandHandler( + "approve", + lambda update, context: update_status_by_username(update, context, UserStatus.ATTENDEE), + ) + ) + application.add_handler( + CommandHandler( + "disapprove", + lambda update, context: update_status_by_username(update, context, UserStatus.WAITLIST), + ) + ) + application.add_handler( + CommandHandler( + "processing", + lambda update, context: update_status_by_username( + update, context, UserStatus.PROCESSING + ), + ) + ) + application.add_handler( + CommandHandler( + "approve_id", + lambda update, context: update_status_by_id(update, context, UserStatus.ATTENDEE), + ) + ) + application.add_handler( + CommandHandler( + "disapprove_id", + lambda update, context: update_status_by_id(update, context, UserStatus.WAITLIST), + ) + ) + application.add_handler( + CommandHandler( + "processing_id", + lambda update, context: update_status_by_id(update, context, UserStatus.PROCESSING), + ) + ) + + application.add_handler( + ConversationHandler( + entry_points=[ + MessageHandler(filters.Regex(f"^{MENU_APPLICATION}$"), application_start) + ], + states={ + APPLICATION_FULL_NAME: [ + MessageHandler( + filters.TEXT & ~filters.COMMAND & ~filters.Regex(f"^{MENU_HOME}$"), + application_full_name, + ) + ], + APPLICATION_JOB: [ + MessageHandler( + filters.TEXT & ~filters.COMMAND & ~filters.Regex(f"^{MENU_HOME}$"), + application_job, + ) + ], + APPLICATION_CAREER: [ + MessageHandler( + filters.TEXT & ~filters.COMMAND & ~filters.Regex(f"^{MENU_HOME}$"), + application_career, + ) + ], + }, + fallbacks=[MessageHandler(filters.Regex(f"^{MENU_HOME}$"), application_cancel)], + ) + ) + + application.add_handler( + ConversationHandler( + entry_points=[MessageHandler(filters.Regex(f"^{MENU_FEEDBACK}$"), feedback_start)], + states={ + FEEDBACK_TEXT: [ + MessageHandler( + filters.TEXT & ~filters.COMMAND & ~filters.Regex(f"^{MENU_HOME}$"), + feedback_save, + ) + ] + }, + fallbacks=[MessageHandler(filters.Regex(f"^{MENU_HOME}$"), feedback_cancel)], + ) + ) + + application.add_handler(MessageHandler(filters.Regex(f"^{MENU_CANCEL}$"), cancel_application)) + application.add_handler(MessageHandler(filters.Regex(f"^{MENU_SCHEDULE}$"), schedule)) + application.add_handler(MessageHandler(filters.Regex(f"^{MENU_STATUS}$"), show_status)) + application.add_handler( + MessageHandler(filters.Regex(f"^{MENU_NOTIFICATIONS}$"), show_notifications) + ) + + application.add_handler(MessageHandler(filters.COMMAND, unknown_command)) diff --git a/app/utils.py b/app/utils.py index e200319..861b99b 100644 --- a/app/utils.py +++ b/app/utils.py @@ -40,11 +40,11 @@ def config_path() -> Path | None: return None -def parse_admin_ids(value: str | List[int] | None) -> List[int]: +def parse_admin_usernames(value: str | List[str] | None) -> List[str]: if value is None: return [] if isinstance(value, list): - return [int(v) for v in value] + return [str(v).lstrip("@").strip().lower() for v in value if str(v).strip()] cleaned = [v.strip() for v in value.split(",") if v.strip()] - return [int(v) for v in cleaned] + return [v.lstrip("@").lower() for v in cleaned] diff --git a/config.yaml b/config.yaml index ed11675..0196072 100644 --- a/config.yaml +++ b/config.yaml @@ -1,11 +1,3 @@ -admin_ids: [] -attendee_limit: 150 -basic_auth_password: change-me -basic_auth_username: admin +admin_usernames: [] database_url: sqlite:///./anonchatbot.db -event_name: Community Event -scheduler_interval_seconds: 60 -telegram_token: abcdefghijklmnopqrstuvwxyz -timezone: Europe/Paris -web_host: 0.0.0.0 -web_port: 8000 +telegram_token: abcdefghijklmnopqrstuvwxyz From 2bd92c269ffd296f79bc1488d9a20ea2d147f66d Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Fri, 26 Dec 2025 13:44:50 +0500 Subject: [PATCH 02/15] tests: update tests because admin page is now not supported --- tests/conftest.py | 1 - tests/test_admin.py | 27 +++++++++++++++++---------- tests/test_localization.py | 4 +--- tests/test_messaging.py | 4 +--- tests/test_posts.py | 4 +--- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5b40913..e5b0e75 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -150,4 +150,3 @@ def shutdown(self, wait: bool = False) -> None: _install_telegram_stubs() _install_apscheduler_stubs() - diff --git a/tests/test_admin.py b/tests/test_admin.py index 1a557be..53d240a 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -121,7 +121,7 @@ def test_registrations_page_includes_limit_form(admin_client, monkeypatch): assert response.status_code == 200 assert "Attendee limit" in response.text assert "Leave blank to remove the limit." in response.text - assert "value=\"50\"" in response.text + assert 'value="50"' in response.text def test_dashboard_renders_summary(admin_client): @@ -130,14 +130,22 @@ def test_dashboard_renders_summary(admin_client): event = SimpleNamespace(id=1, name="Community Event", capacity=100) session.scalar.side_effect = [event, 2] session.execute.side_effect = [ - MagicMock(all=MagicMock(return_value=[ - (RegistrationStatus.APPROVED, 5), - (RegistrationStatus.WAITLISTED, 1), - ])), - MagicMock(all=MagicMock(return_value=[ - (RegistrationCategory.ATTENDEE, 10), - (RegistrationCategory.LECTURER, 3), - ])), + MagicMock( + all=MagicMock( + return_value=[ + (RegistrationStatus.APPROVED, 5), + (RegistrationStatus.WAITLISTED, 1), + ] + ) + ), + MagicMock( + all=MagicMock( + return_value=[ + (RegistrationCategory.ATTENDEE, 10), + (RegistrationCategory.LECTURER, 3), + ] + ) + ), _make_scalar_result([]), ] @@ -375,4 +383,3 @@ def test_update_status_updates_registration(admin_client, monkeypatch): "admin_notifications.approved_priority" ) assert bot.send_message.call_args.kwargs["text"] == expected_message - diff --git a/tests/test_localization.py b/tests/test_localization.py index 8ccbbcf..71fd2ac 100644 --- a/tests/test_localization.py +++ b/tests/test_localization.py @@ -4,9 +4,7 @@ def test_localizer_returns_known_strings(): localizer = get_localizer(DEFAULT_LOCALE) assert localizer.get("buttons.attend") == "Attend the event" - formatted = localizer.format( - "start.returning", name="Alex", summary="• Attendee: Approved" - ) + formatted = localizer.format("start.returning", name="Alex", summary="• Attendee: Approved") assert "Alex" in formatted assert "Approved" in formatted diff --git a/tests/test_messaging.py b/tests/test_messaging.py index a7cea51..6d7b40a 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -35,9 +35,7 @@ def test_broadcast_message_delivers_and_unsubscribes(): SimpleNamespace(id=3, telegram_id=200, is_subscribed=True), ] session = FakeSession(users) - bot = SimpleNamespace( - send_message=AsyncMock(side_effect=[None, TelegramError("failed")]) - ) + bot = SimpleNamespace(send_message=AsyncMock(side_effect=[None, TelegramError("failed")])) delivered = asyncio.run(messaging.broadcast_message(session, bot, "Hello")) diff --git a/tests/test_posts.py b/tests/test_posts.py index ab33e08..f4cb256 100644 --- a/tests/test_posts.py +++ b/tests/test_posts.py @@ -66,9 +66,7 @@ def execute(self, statement): return FakeResult() session = FakeSession() - bot = SimpleNamespace( - send_message=AsyncMock(side_effect=[None, TelegramError("oops")]) - ) + bot = SimpleNamespace(send_message=AsyncMock(side_effect=[None, TelegramError("oops")])) post = SimpleNamespace(id=5, title="Title", content="Content", sent_at=None) asyncio.run(posts.broadcast_post(session, bot, post)) From 5b5848336cbb5b9baae11de4ca489d36985d06e7 Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Fri, 26 Dec 2025 13:45:34 +0500 Subject: [PATCH 03/15] uv|pyptoject: drop unused deps --- pyproject.toml | 6 - uv.lock | 394 +------------------------------------------------ 2 files changed, 8 insertions(+), 392 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 239fa09..e90cbfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,14 +8,9 @@ authors = [ { name = "Community" } ] dependencies = [ - "apscheduler>=3.10", - "fastapi>=0.110", - "jinja2>=3.1", "pydantic>=1.10,<2", - "python-multipart>=0.0.6", "python-telegram-bot>=20.7", "sqlalchemy>=2.0", - "uvicorn[standard]>=0.23", "pyyaml>=6.0", ] @@ -44,4 +39,3 @@ select = ["E", "F", "I", "B"] [tool.ruff.format] quote-style = "double" indent-style = "space" - diff --git a/uv.lock b/uv.lock index eaba4a8..f32977b 100644 --- a/uv.lock +++ b/uv.lock @@ -7,15 +7,10 @@ name = "anonchatbot" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "apscheduler" }, - { name = "fastapi" }, - { name = "jinja2" }, { name = "pydantic" }, - { name = "python-multipart" }, { name = "python-telegram-bot" }, { name = "pyyaml" }, { name = "sqlalchemy" }, - { name = "uvicorn", extra = ["standard"] }, ] [package.dev-dependencies] @@ -28,15 +23,10 @@ dev = [ [package.metadata] requires-dist = [ - { name = "apscheduler", specifier = ">=3.10" }, - { name = "fastapi", specifier = ">=0.110" }, - { name = "jinja2", specifier = ">=3.1" }, { name = "pydantic", specifier = ">=1.10,<2" }, - { name = "python-multipart", specifier = ">=0.0.6" }, { name = "python-telegram-bot", specifier = ">=20.7" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "sqlalchemy", specifier = ">=2.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.23" }, ] [package.metadata.requires-dev] @@ -61,18 +51,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] -[[package]] -name = "apscheduler" -version = "3.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzlocal" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, -] - [[package]] name = "black" version = "25.9.0" @@ -224,20 +202,6 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] -[[package]] -name = "fastapi" -version = "0.119.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0a/f9/5c5bcce82a7997cc0eb8c47b7800f862f6b56adc40486ed246e5010d443b/fastapi-0.119.0.tar.gz", hash = "sha256:451082403a2c1f0b99c6bd57c09110ed5463856804c8078d38e5a1f1035dbbb7", size = 336756, upload-time = "2025-10-11T17:13:40.53Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/70/584c4d7cad80f5e833715c0a29962d7c93b4d18eed522a02981a6d1b6ee5/fastapi-0.119.0-py3-none-any.whl", hash = "sha256:90a2e49ed19515320abb864df570dd766be0662c5d577688f1600170f7f73cf2", size = 107095, upload-time = "2025-10-11T17:13:39.048Z" }, -] - [[package]] name = "greenlet" version = "3.2.4" @@ -252,6 +216,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" }, { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, @@ -261,6 +227,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, @@ -270,6 +238,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, @@ -277,6 +247,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] @@ -302,42 +274,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] -[[package]] -name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, -] - [[package]] name = "httpx" version = "0.28.1" @@ -371,92 +307,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - [[package]] name = "mypy-extensions" version = "1.1.0" @@ -574,24 +424,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, -] - [[package]] name = "python-telegram-bot" version = "22.5" @@ -740,19 +572,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" }, ] -[[package]] -name = "starlette" -version = "0.48.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, -] - [[package]] name = "tomli" version = "2.3.0" @@ -810,200 +629,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] - -[[package]] -name = "tzdata" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, -] - -[[package]] -name = "tzlocal" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.21.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload-time = "2024-10-14T23:37:33.612Z" }, - { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload-time = "2024-10-14T23:37:36.11Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload-time = "2024-10-14T23:37:37.683Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185, upload-time = "2024-10-14T23:37:40.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256, upload-time = "2024-10-14T23:37:42.839Z" }, - { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323, upload-time = "2024-10-14T23:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, - { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, - { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, - { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, - { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, - { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, - { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, - { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, -] - -[[package]] -name = "watchfiles" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/78/7401154b78ab484ccaaeef970dc2af0cb88b5ba8a1b415383da444cdd8d3/watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2", size = 405751, upload-time = "2025-06-15T19:05:07.679Z" }, - { url = "https://files.pythonhosted.org/packages/76/63/e6c3dbc1f78d001589b75e56a288c47723de28c580ad715eb116639152b5/watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c", size = 397313, upload-time = "2025-06-15T19:05:08.764Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a2/8afa359ff52e99af1632f90cbf359da46184207e893a5f179301b0c8d6df/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d", size = 450792, upload-time = "2025-06-15T19:05:09.869Z" }, - { url = "https://files.pythonhosted.org/packages/1d/bf/7446b401667f5c64972a57a0233be1104157fc3abf72c4ef2666c1bd09b2/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7", size = 458196, upload-time = "2025-06-15T19:05:11.91Z" }, - { url = "https://files.pythonhosted.org/packages/58/2f/501ddbdfa3fa874ea5597c77eeea3d413579c29af26c1091b08d0c792280/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c", size = 484788, upload-time = "2025-06-15T19:05:13.373Z" }, - { url = "https://files.pythonhosted.org/packages/61/1e/9c18eb2eb5c953c96bc0e5f626f0e53cfef4bd19bd50d71d1a049c63a575/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575", size = 597879, upload-time = "2025-06-15T19:05:14.725Z" }, - { url = "https://files.pythonhosted.org/packages/8b/6c/1467402e5185d89388b4486745af1e0325007af0017c3384cc786fff0542/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8", size = 477447, upload-time = "2025-06-15T19:05:15.775Z" }, - { url = "https://files.pythonhosted.org/packages/2b/a1/ec0a606bde4853d6c4a578f9391eeb3684a9aea736a8eb217e3e00aa89a1/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f", size = 453145, upload-time = "2025-06-15T19:05:17.17Z" }, - { url = "https://files.pythonhosted.org/packages/90/b9/ef6f0c247a6a35d689fc970dc7f6734f9257451aefb30def5d100d6246a5/watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4", size = 626539, upload-time = "2025-06-15T19:05:18.557Z" }, - { url = "https://files.pythonhosted.org/packages/34/44/6ffda5537085106ff5aaa762b0d130ac6c75a08015dd1621376f708c94de/watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d", size = 624472, upload-time = "2025-06-15T19:05:19.588Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e3/71170985c48028fa3f0a50946916a14055e741db11c2e7bc2f3b61f4d0e3/watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2", size = 279348, upload-time = "2025-06-15T19:05:20.856Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/3e39c68b68a7a171070f81fc2561d23ce8d6859659406842a0e4bebf3bba/watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12", size = 292607, upload-time = "2025-06-15T19:05:21.937Z" }, - { url = "https://files.pythonhosted.org/packages/61/9f/2973b7539f2bdb6ea86d2c87f70f615a71a1fc2dba2911795cea25968aea/watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a", size = 285056, upload-time = "2025-06-15T19:05:23.12Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b8/858957045a38a4079203a33aaa7d23ea9269ca7761c8a074af3524fbb240/watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179", size = 402339, upload-time = "2025-06-15T19:05:24.516Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/98b222cca751ba68e88521fabd79a4fab64005fc5976ea49b53fa205d1fa/watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5", size = 394409, upload-time = "2025-06-15T19:05:25.469Z" }, - { url = "https://files.pythonhosted.org/packages/86/50/dee79968566c03190677c26f7f47960aff738d32087087bdf63a5473e7df/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297", size = 450939, upload-time = "2025-06-15T19:05:26.494Z" }, - { url = "https://files.pythonhosted.org/packages/40/45/a7b56fb129700f3cfe2594a01aa38d033b92a33dddce86c8dfdfc1247b72/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0", size = 457270, upload-time = "2025-06-15T19:05:27.466Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c8/fa5ef9476b1d02dc6b5e258f515fcaaecf559037edf8b6feffcbc097c4b8/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e", size = 483370, upload-time = "2025-06-15T19:05:28.548Z" }, - { url = "https://files.pythonhosted.org/packages/98/68/42cfcdd6533ec94f0a7aab83f759ec11280f70b11bfba0b0f885e298f9bd/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee", size = 598654, upload-time = "2025-06-15T19:05:29.997Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/b2a1544224118cc28df7e59008a929e711f9c68ce7d554e171b2dc531352/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd", size = 478667, upload-time = "2025-06-15T19:05:31.172Z" }, - { url = "https://files.pythonhosted.org/packages/8c/77/e3362fe308358dc9f8588102481e599c83e1b91c2ae843780a7ded939a35/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f", size = 452213, upload-time = "2025-06-15T19:05:32.299Z" }, - { url = "https://files.pythonhosted.org/packages/6e/17/c8f1a36540c9a1558d4faf08e909399e8133599fa359bf52ec8fcee5be6f/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4", size = 626718, upload-time = "2025-06-15T19:05:33.415Z" }, - { url = "https://files.pythonhosted.org/packages/26/45/fb599be38b4bd38032643783d7496a26a6f9ae05dea1a42e58229a20ac13/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f", size = 623098, upload-time = "2025-06-15T19:05:34.534Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/fdf40e038475498e160cd167333c946e45d8563ae4dd65caf757e9ffe6b4/watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd", size = 279209, upload-time = "2025-06-15T19:05:35.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d3/3ae9d5124ec75143bdf088d436cba39812122edc47709cd2caafeac3266f/watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47", size = 292786, upload-time = "2025-06-15T19:05:36.559Z" }, - { url = "https://files.pythonhosted.org/packages/26/2f/7dd4fc8b5f2b34b545e19629b4a018bfb1de23b3a496766a2c1165ca890d/watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6", size = 284343, upload-time = "2025-06-15T19:05:37.5Z" }, - { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" }, - { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" }, - { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" }, - { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" }, - { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" }, - { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" }, - { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" }, - { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" }, - { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" }, - { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" }, - { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" }, - { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" }, - { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" }, - { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" }, - { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" }, - { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" }, - { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" }, - { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" }, - { url = "https://files.pythonhosted.org/packages/2c/00/70f75c47f05dea6fd30df90f047765f6fc2d6eb8b5a3921379b0b04defa2/watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297", size = 402114, upload-time = "2025-06-15T19:06:06.186Z" }, - { url = "https://files.pythonhosted.org/packages/53/03/acd69c48db4a1ed1de26b349d94077cca2238ff98fd64393f3e97484cae6/watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018", size = 393879, upload-time = "2025-06-15T19:06:07.369Z" }, - { url = "https://files.pythonhosted.org/packages/2f/c8/a9a2a6f9c8baa4eceae5887fecd421e1b7ce86802bcfc8b6a942e2add834/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0", size = 450026, upload-time = "2025-06-15T19:06:08.476Z" }, - { url = "https://files.pythonhosted.org/packages/fe/51/d572260d98388e6e2b967425c985e07d47ee6f62e6455cefb46a6e06eda5/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12", size = 457917, upload-time = "2025-06-15T19:06:09.988Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/4258e52917bf9f12909b6ec314ff9636276f3542f9d3807d143f27309104/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb", size = 483602, upload-time = "2025-06-15T19:06:11.088Z" }, - { url = "https://files.pythonhosted.org/packages/84/99/bee17a5f341a4345fe7b7972a475809af9e528deba056f8963d61ea49f75/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77", size = 596758, upload-time = "2025-06-15T19:06:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/e4bec1d59b25b89d2b0716b41b461ed655a9a53c60dc78ad5771fda5b3e6/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92", size = 477601, upload-time = "2025-06-15T19:06:13.391Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fa/a514292956f4a9ce3c567ec0c13cce427c158e9f272062685a8a727d08fc/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e", size = 451936, upload-time = "2025-06-15T19:06:14.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/5d/c3bf927ec3bbeb4566984eba8dd7a8eb69569400f5509904545576741f88/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b", size = 626243, upload-time = "2025-06-15T19:06:16.232Z" }, - { url = "https://files.pythonhosted.org/packages/e6/65/6e12c042f1a68c556802a84d54bb06d35577c81e29fba14019562479159c/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259", size = 623073, upload-time = "2025-06-15T19:06:17.457Z" }, - { url = "https://files.pythonhosted.org/packages/89/ab/7f79d9bf57329e7cbb0a6fd4c7bd7d0cee1e4a8ef0041459f5409da3506c/watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f", size = 400872, upload-time = "2025-06-15T19:06:18.57Z" }, - { url = "https://files.pythonhosted.org/packages/df/d5/3f7bf9912798e9e6c516094db6b8932df53b223660c781ee37607030b6d3/watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e", size = 392877, upload-time = "2025-06-15T19:06:19.55Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c5/54ec7601a2798604e01c75294770dbee8150e81c6e471445d7601610b495/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa", size = 449645, upload-time = "2025-06-15T19:06:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/0a/04/c2f44afc3b2fce21ca0b7802cbd37ed90a29874f96069ed30a36dfe57c2b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8", size = 457424, upload-time = "2025-06-15T19:06:21.712Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b0/eec32cb6c14d248095261a04f290636da3df3119d4040ef91a4a50b29fa5/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f", size = 481584, upload-time = "2025-06-15T19:06:22.777Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/ca4bb71c68a937d7145aa25709e4f5d68eb7698a25ce266e84b55d591bbd/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e", size = 596675, upload-time = "2025-06-15T19:06:24.226Z" }, - { url = "https://files.pythonhosted.org/packages/a1/dd/b0e4b7fb5acf783816bc950180a6cd7c6c1d2cf7e9372c0ea634e722712b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb", size = 477363, upload-time = "2025-06-15T19:06:25.42Z" }, - { url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240, upload-time = "2025-06-15T19:06:26.552Z" }, - { url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607, upload-time = "2025-06-15T19:06:27.606Z" }, - { url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6b/686dcf5d3525ad17b384fd94708e95193529b460a1b7bf40851f1328ec6e/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3", size = 406910, upload-time = "2025-06-15T19:06:49.335Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d3/71c2dcf81dc1edcf8af9f4d8d63b1316fb0a2dd90cbfd427e8d9dd584a90/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c", size = 398816, upload-time = "2025-06-15T19:06:50.433Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fa/12269467b2fc006f8fce4cd6c3acfa77491dd0777d2a747415f28ccc8c60/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432", size = 451584, upload-time = "2025-06-15T19:06:51.834Z" }, - { url = "https://files.pythonhosted.org/packages/bd/d3/254cea30f918f489db09d6a8435a7de7047f8cb68584477a515f160541d6/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792", size = 454009, upload-time = "2025-06-15T19:06:52.896Z" }, -] - -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, -] From b16050bb2ef6e0a24cc1b7c9063b4524212a3eb2 Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Fri, 26 Dec 2025 13:45:51 +0500 Subject: [PATCH 04/15] readme: simplify and add new info --- README.md | 99 ++++++++++++++----------------------------------------- 1 file changed, 25 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index bae7f32..cbd5a18 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,45 @@ # Event Telegram Bot -A modernised Telegram bot that manages registrations for events, with a lightweight admin panel to approve attendees, schedule announcements and send urgent notifications. +A Telegram bot for event registrations with an admin toolset to review applications and send announcements. -## Features +## What it does -- Attendee registration with automatic waitlisting when the configured capacity is reached. -- Separate lecturer and project showcase registration categories with no capacity limits. -- Admin panel protected by HTTP Basic authentication: - - Approve or reject attendees, lecturers and showcase presenters. - - Manually add priority attendees that should bypass the capacity limit. - - Schedule broadcast posts that are sent to every subscribed user. - - Send urgent notifications to all participants instantly. -- Periodic scheduler that delivers planned posts automatically. -- SQLite-backed persistence by default (configurable database URL) so data survives redeployments. +- Collects attendee registrations and waitlists when capacity is full. +- Supports additional registration categories (e.g., lecturers, showcases). +- Lets admins approve/reject applicants and send scheduled or urgent broadcasts. +- Persists data in a SQL database (SQLite by default). -## Configuration - -Runtime configuration is stored in `config.yaml`. Update this file (or mount an alternative and point `CONFIG_FILE` to it) with your Telegram token, admin credentials and other limits before starting the bot. - -You can still override any value via environment variables (`TELEGRAM_TOKEN`, `ADMIN_IDS`, etc.) for secrets that should not live in the YAML file. - -## Getting started - -1. Install [uv](https://github.com/astral-sh/uv) (already bundled in the Docker image). With uv available locally you can bootstrap the project with the included Makefile: +## Quick start - ```bash - make dev-install - ``` - - If uv is not available, install the dependencies listed in `requirements.txt` with `pip install -r requirements.txt` and the dev tools (`pytest`, `pytest-cov`, etc.) as needed for your workflow. - -2. Run the bot: - - ```bash - make run - ``` +```bash +make docker-build +make docker-up +``` - The Telegram bot will start polling and the admin interface will be available at `http://localhost:8000/admin/posts`. +The bot will start polling. Admin commands are available to Telegram users listed in the config. -3. Format, lint and test: +## Configuration - ```bash - make format - make lint - make test - ``` +Default config lives in `config.yaml` (or point `CONFIG_FILE` to another file): -To create a lockfile for reproducible builds run `uv lock` and adjust the Dockerfile to copy it into the image. +- `telegram_token`: bot token +- `admin_usernames`: list of Telegram usernames allowed to use admin commands +- `database_url`: SQLAlchemy database URL -## Docker workflow +You can override any value with environment variables like `TELEGRAM_TOKEN`, `ADMIN_USERNAMES`, and `DATABASE_URL`. -The repository includes a `Dockerfile` and `docker-compose.yml` that rely on uv for dependency management. +## Docker ```bash docker compose up --build ``` -By default the compose file mounts `config.yaml` into the container. Provide your production configuration before deploying. - -## Deployment - -- The project uses uv and a `config.yaml` file for configuration, making it easy to deploy with Docker or any container orchestrator. -- Data lives in the configured SQL database. When redeploying, point `DATABASE_URL` to the same storage (for SQLite mount the volume, for Postgres/MySQL use the external service) to retain registrations and scheduled posts. -- User-facing copy is resolved through JSON localization files stored under `app/locales/`. Set the `LOCALE` environment variable (or the corresponding YAML field) to switch languages at runtime and add new translation files alongside `en.json`. +Mount your `config.yaml` into the container or set env vars for production. -## Project structure +## Development +```bash +make format FIX=1 +make lint +make docker-test ``` -app/ - config.py # Settings and environment loading - database.py # SQLAlchemy engine & session helpers - main.py # Entry point that runs the bot and the admin server - models.py # ORM models - scheduler.py # APScheduler integration - services/ # Business logic for registrations, posts and messaging - telegram/ # Telegram bot application & handlers - web/ # FastAPI admin interface -config.yaml # Runtime configuration loaded by default -pyproject.toml # Project metadata and dependency declarations -Dockerfile # uv-based container build -Makefile # Developer shortcuts (install, lint, test, run) -docker-compose.yml # Local container orchestration -``` - -## Admin credentials and security - -Set strong values for `ADMIN_USERNAME` and `ADMIN_PASSWORD`. For production deployments, run the admin panel behind HTTPS (for example via a reverse proxy or the hosting provider) to protect credentials in transit. - -## Development tips - -- Use SQLite locally (default) and Postgres/MySQL in production by changing `DATABASE_URL`. -- Adjust the scheduler frequency with `SCHEDULER_INTERVAL_SECONDS` (defaults to 60 seconds). -- To reset the database, delete the SQLite file (`anonchatbot.db`) or drop the tables in your SQL server. From 8e66201e20743bc05b4fc166bb813401cf94d537 Mon Sep 17 00:00:00 2001 From: Nikita Efremov Date: Tue, 23 Dec 2025 14:49:12 +0500 Subject: [PATCH 05/15] tests: replace redundant tests --- tests/test_admin.py | 385 ------------------------------------ tests/test_admin_service.py | 25 --- tests/test_config.py | 37 ++-- tests/test_database.py | 64 ++++++ tests/test_events.py | 76 ------- tests/test_handlers.py | 97 +++++++++ tests/test_messaging.py | 46 ----- tests/test_posts.py | 76 ------- tests/test_registrations.py | 127 ------------ tests/test_scheduler.py | 110 ----------- tests/test_users.py | 58 ------ tests/test_utils.py | 18 ++ 12 files changed, 190 insertions(+), 929 deletions(-) delete mode 100644 tests/test_admin.py delete mode 100644 tests/test_admin_service.py create mode 100644 tests/test_database.py delete mode 100644 tests/test_events.py create mode 100644 tests/test_handlers.py delete mode 100644 tests/test_messaging.py delete mode 100644 tests/test_posts.py delete mode 100644 tests/test_registrations.py delete mode 100644 tests/test_scheduler.py delete mode 100644 tests/test_users.py create mode 100644 tests/test_utils.py diff --git a/tests/test_admin.py b/tests/test_admin.py deleted file mode 100644 index 53d240a..0000000 --- a/tests/test_admin.py +++ /dev/null @@ -1,385 +0,0 @@ -import os -from datetime import datetime, timezone -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock -from urllib.parse import parse_qs, urlparse - -import sys -import types - -import pytest -from fastapi.testclient import TestClient - -os.environ.setdefault("TELEGRAM_TOKEN", "dummy-token") -os.environ.setdefault("ADMIN_USERNAME", "admin") -os.environ.setdefault("ADMIN_PASSWORD", "secret") - -sys.modules.setdefault("telebot", types.SimpleNamespace(Bot=object)) -sys.modules.setdefault("telebot.error", types.SimpleNamespace(TelegramError=Exception)) - -import app.config as app_config -from app.config import Settings -from app.database import get_session -from app.localization import get_localizer -from app.models import RegistrationCategory, RegistrationStatus -from app.web import admin as admin_module - - -def _make_scalar_result(values): - result = MagicMock() - scalars = MagicMock() - scalars.all.return_value = values - result.scalars.return_value = scalars - return result - - -def _override_session(session): - def dependency(): - yield session - - return dependency - - -@pytest.fixture -def settings(): - return Settings( - telegram_token="token", - admin_ids=[], - basic_auth_username="admin", - basic_auth_password="secret", - ) - - -@pytest.fixture -def admin_client(settings): - bot = MagicMock() - bot.send_message = AsyncMock() - app = admin_module.create_app(settings, bot=bot) - client = TestClient(app) - try: - yield client, app, bot - finally: - client.close() - - -def test_index_redirects_to_dashboard(admin_client): - client, app, _ = admin_client - session = MagicMock() - app.dependency_overrides[get_session] = _override_session(session) - - response = client.get("/", auth=("admin", "secret"), follow_redirects=False) - - assert response.status_code == 302 - assert response.headers["location"] == "/admin" - - -def test_posts_page_renders_lists(admin_client): - client, app, _ = admin_client - upcoming_post = SimpleNamespace( - id=1, - title="Future", - content="Soon", - send_at=datetime.now(timezone.utc), - ) - sent_post = SimpleNamespace( - id=2, - title="Past", - content="Earlier", - sent_at=datetime.now(timezone.utc), - ) - - session = MagicMock() - session.execute.side_effect = [ - _make_scalar_result([upcoming_post]), - _make_scalar_result([sent_post]), - ] - - app.dependency_overrides[get_session] = _override_session(session) - - response = client.get("/admin/posts", auth=("admin", "secret")) - - assert response.status_code == 200 - assert "Future" in response.text - assert "Past" in response.text - assert "Timezone preferences" in response.text - assert "Europe/Moscow" in response.text - assert "Asia/Almaty" in response.text - - -def test_registrations_page_includes_limit_form(admin_client, monkeypatch): - client, app, _ = admin_client - session = MagicMock() - session.execute.return_value = _make_scalar_result([]) - session.scalar.return_value = 0 - app.dependency_overrides[get_session] = _override_session(session) - - event = SimpleNamespace(id=1, capacity=50) - monkeypatch.setattr(admin_module, "get_or_create_default_event", lambda *args, **kwargs: event) - - response = client.get("/admin/registrations", auth=("admin", "secret")) - - assert response.status_code == 200 - assert "Attendee limit" in response.text - assert "Leave blank to remove the limit." in response.text - assert 'value="50"' in response.text - - -def test_dashboard_renders_summary(admin_client): - client, app, _ = admin_client - session = MagicMock() - event = SimpleNamespace(id=1, name="Community Event", capacity=100) - session.scalar.side_effect = [event, 2] - session.execute.side_effect = [ - MagicMock( - all=MagicMock( - return_value=[ - (RegistrationStatus.APPROVED, 5), - (RegistrationStatus.WAITLISTED, 1), - ] - ) - ), - MagicMock( - all=MagicMock( - return_value=[ - (RegistrationCategory.ATTENDEE, 10), - (RegistrationCategory.LECTURER, 3), - ] - ) - ), - _make_scalar_result([]), - ] - - app.dependency_overrides[get_session] = _override_session(session) - - response = client.get("/admin", auth=("admin", "secret")) - - assert response.status_code == 200 - assert "Event overview" in response.text - assert "Upcoming posts" in response.text - - -def test_create_post_schedules(admin_client, monkeypatch): - client, app, _ = admin_client - session = MagicMock() - app.dependency_overrides[get_session] = _override_session(session) - - schedule_post = MagicMock() - monkeypatch.setattr(admin_module, "schedule_post", schedule_post) - - response = client.post( - "/admin/posts", - data={ - "title": "Hello", - "content": "World", - "send_at": "2024-01-01T12:00", - }, - auth=("admin", "secret"), - follow_redirects=False, - ) - - assert response.status_code == 303 - assert response.headers["location"].startswith("/admin/posts") - schedule_post.assert_called_once() - _, kwargs = schedule_post.call_args - assert kwargs["title"] == "Hello" - assert kwargs["content"] == "World" - assert kwargs["send_at"].tzinfo is not None - - -def test_update_limit_removes_capacity(admin_client): - client, app, _ = admin_client - session = MagicMock() - event = SimpleNamespace(capacity=123) - session.scalar.return_value = event - session.flush = MagicMock() - app.dependency_overrides[get_session] = _override_session(session) - - response = client.post( - "/admin/event/limit", - data={"limit": " "}, - auth=("admin", "secret"), - follow_redirects=False, - ) - - assert response.status_code == 303 - assert "Attendee%20limit%20removed" in response.headers["location"] - assert event.capacity is None - assert app.state.settings.attendee_limit is None - session.flush.assert_called_once() - - -def test_update_limit_sets_new_capacity(admin_client): - client, app, _ = admin_client - session = MagicMock() - event = SimpleNamespace(capacity=None) - session.scalar.return_value = event - session.flush = MagicMock() - app.dependency_overrides[get_session] = _override_session(session) - - response = client.post( - "/admin/event/limit", - data={"limit": "25"}, - auth=("admin", "secret"), - follow_redirects=False, - ) - - assert response.status_code == 303 - assert "Attendee%20limit%20updated" in response.headers["location"] - assert event.capacity == 25 - assert app.state.settings.attendee_limit == 25 - session.flush.assert_called_once() - - -def test_update_limit_requires_integer(admin_client): - client, app, _ = admin_client - session = MagicMock() - event = SimpleNamespace(capacity=None) - session.scalar.return_value = event - app.dependency_overrides[get_session] = _override_session(session) - - response = client.post( - "/admin/event/limit", - data={"limit": "many"}, - auth=("admin", "secret"), - follow_redirects=False, - ) - - assert response.status_code == 400 - assert response.json()["detail"] == "Limit must be an integer" - - -def test_update_limit_disallows_negative_values(admin_client): - client, app, _ = admin_client - session = MagicMock() - event = SimpleNamespace(capacity=None) - session.scalar.return_value = event - app.dependency_overrides[get_session] = _override_session(session) - - response = client.post( - "/admin/event/limit", - data={"limit": "-1"}, - auth=("admin", "secret"), - follow_redirects=False, - ) - - assert response.status_code == 400 - assert response.json()["detail"] == "Limit must be zero or greater" - - -def test_update_timezone_changes_setting(admin_client): - client, app, _ = admin_client - session = MagicMock() - app.dependency_overrides[get_session] = _override_session(session) - - response = client.post( - "/admin/settings/timezone", - data={ - "timezone_value": "Europe/Paris", - "return_to": "/admin/posts", - }, - auth=("admin", "secret"), - follow_redirects=False, - ) - - assert response.status_code == 303 - assert response.headers["location"].startswith("/admin/posts") - assert app.state.settings.timezone == "Europe/Paris" - - -def test_update_timezone_rejects_invalid(admin_client): - client, app, _ = admin_client - - response = client.post( - "/admin/settings/timezone", - data={"timezone_value": "Mars/Phobos"}, - auth=("admin", "secret"), - ) - - assert response.status_code == 400 - - -def test_update_timezone_handles_read_only(admin_client, monkeypatch, tmp_path): - client, app, _ = admin_client - config_file = tmp_path / "config.yaml" - config_file.write_text("timezone: UTC\n", encoding="utf-8") - config_file.chmod(0o400) - - monkeypatch.setattr(app_config, "config_path", lambda: config_file) - - session = MagicMock() - app.dependency_overrides[get_session] = _override_session(session) - - response = client.post( - "/admin/settings/timezone", - data={ - "timezone_value": "Europe/Moscow", - "return_to": "/admin/posts", - }, - auth=("admin", "secret"), - follow_redirects=False, - ) - - config_file.chmod(0o600) - - assert response.status_code == 303 - redirect = urlparse(response.headers["location"]) - params = parse_qs(redirect.query) - assert "msg" in params - assert "(not saved to disk)" in params["msg"][0] - assert app.state.settings.timezone == "Europe/Moscow" - - -def test_send_urgent_broadcasts_message(admin_client, monkeypatch): - client, app, bot = admin_client - session = MagicMock() - app.dependency_overrides[get_session] = _override_session(session) - - broadcast = AsyncMock(return_value=5) - monkeypatch.setattr(admin_module, "broadcast_message", broadcast) - - response = client.post( - "/admin/urgent", - data={"message": "Alert!"}, - auth=("admin", "secret"), - ) - - assert response.status_code == 200 - broadcast.assert_awaited_once_with(session, bot, "Alert!") - assert "Alert!" in response.text - - -def test_update_status_updates_registration(admin_client, monkeypatch): - client, app, bot = admin_client - session = MagicMock() - app.dependency_overrides[get_session] = _override_session(session) - - registration = SimpleNamespace(user=SimpleNamespace(telegram_id=123), is_priority=True) - session.get.return_value = registration - - update_status = MagicMock() - monkeypatch.setattr(admin_module, "update_registration_status", update_status) - - create_task = MagicMock() - monkeypatch.setattr(admin_module.asyncio, "create_task", create_task) - - response = client.post( - "/admin/registrations/1/status", - data={ - "status_value": "approved", - "priority_value": "true", - "return_to": "/admin/registrations/approved", - }, - auth=("admin", "secret"), - follow_redirects=False, - ) - - assert response.status_code == 303 - assert response.headers["location"] == "/admin/registrations/approved" - update_status.assert_called_once() - create_task.assert_called_once() - created_coro = create_task.call_args[0][0] - created_coro.close() - expected_message = get_localizer(app.state.settings.locale).get( - "admin_notifications.approved_priority" - ) - assert bot.send_message.call_args.kwargs["text"] == expected_message diff --git a/tests/test_admin_service.py b/tests/test_admin_service.py deleted file mode 100644 index 5a309aa..0000000 --- a/tests/test_admin_service.py +++ /dev/null @@ -1,25 +0,0 @@ -from app.models import Event, RegistrationCategory -from app.services import admin -from tests.test_events import make_session - - -def test_create_manual_attendee_creates_user_and_registration(): - with make_session() as session: - event = Event(name="Test", capacity=10) - session.add(event) - session.commit() - - registration = admin.create_manual_attendee( - session, - event=event, - display_name="Guest", - contact="guest@example.com", - category=RegistrationCategory.ATTENDEE, - notes="VIP", - is_priority=False, - ) - - assert registration.user.display_name == "Guest" - assert registration.category == RegistrationCategory.ATTENDEE - assert registration.notes == "VIP" - assert registration.is_priority is False diff --git a/tests/test_config.py b/tests/test_config.py index 90f37c3..84d8f59 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,8 +4,8 @@ from app import utils -def test_parse_admin_ids_from_string(): - assert Settings._parse_admin_ids("1, 2,3") == [1, 2, 3] +def test_parse_admin_usernames_from_string(): + assert Settings._parse_admin_usernames("Admin, @Owner") == ["admin", "owner"] @pytest.fixture(autouse=True) @@ -20,22 +20,19 @@ def test_settings_loaded_from_yaml(tmp_path, monkeypatch): config_path.write_text( """ telegram_token: test-token -admin_ids: - - 123 -basic_auth_username: admin -basic_auth_password: secret +admin_usernames: + - admin + - "@Owner" """ ) monkeypatch.setenv("CONFIG_FILE", str(config_path)) monkeypatch.setenv("TELEGRAM_TOKEN", "test-token") - monkeypatch.setenv("ADMIN_USERNAME", "admin") - monkeypatch.setenv("ADMIN_PASSWORD", "secret") settings = get_settings() assert settings.telegram_token == "test-token" - assert settings.admin_ids == [123] - assert 123 in settings.admin_id_set + assert settings.admin_usernames == ["admin", "owner"] + assert settings.admin_username_set == {"admin", "owner"} monkeypatch.delenv("CONFIG_FILE", raising=False) @@ -64,19 +61,7 @@ def test_config_path_falls_back_to_search_paths(tmp_path, monkeypatch): assert utils.config_path() == config_file -def test_set_timezone_respects_read_only_file(tmp_path, monkeypatch): - config_file = tmp_path / "config.yaml" - config_file.write_text("timezone: UTC\n") - config_file.chmod(0o400) - - monkeypatch.setenv("CONFIG_FILE", str(config_file)) - settings = Settings( - telegram_token="token", - basic_auth_username="admin", - basic_auth_password="secret", - ) - - persisted = settings.set_timezone("Europe/London") - - assert persisted is False - assert settings.timezone == "Europe/London" +def test_settings_respects_admin_usernames_env(monkeypatch): + monkeypatch.setenv("ADMIN_USERNAMES", '["first", "Second"]') + settings = Settings(telegram_token="token") + assert settings.admin_usernames == ["first", "second"] diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..379af93 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,64 @@ +import importlib + +import pytest +from sqlalchemy import text + +import app.config as config + + +def _reload_database(tmp_path, monkeypatch): + monkeypatch.setenv("TELEGRAM_TOKEN", "token") + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'test.db'}") + config.get_settings.cache_clear() + + import app.database as database + import app.models as models + + database = importlib.reload(database) + models = importlib.reload(models) + return database, models + + +def test_ensure_schema_sets_version_and_tables(tmp_path, monkeypatch): + database, _ = _reload_database(tmp_path, monkeypatch) + + database.ensure_schema() + + with database.engine.begin() as connection: + version = connection.execute(text("SELECT version FROM schema_version")).scalar() + tables = { + row[0] + for row in connection.execute( + text("SELECT name FROM sqlite_master WHERE type='table'") + ).all() + } + + assert version == database.SCHEMA_VERSION + assert {"users", "feedback", "message_templates", "admin_state", "event_state"}.issubset(tables) + + config.get_settings.cache_clear() + + +def test_session_scope_commits_and_rolls_back(tmp_path, monkeypatch): + database, models = _reload_database(tmp_path, monkeypatch) + database.ensure_schema() + + with database.session_scope() as session: + initial_count = session.query(models.SchemaVersion).count() + session.add(models.SchemaVersion(version=2)) + + with database.session_scope() as session: + assert session.query(models.SchemaVersion).count() == initial_count + 1 + + class DummyError(Exception): + pass + + with pytest.raises(DummyError): + with database.session_scope() as session: + session.add(models.SchemaVersion(version=3)) + raise DummyError + + with database.session_scope() as session: + assert session.query(models.SchemaVersion).count() == initial_count + 1 + + config.get_settings.cache_clear() diff --git a/tests/test_events.py b/tests/test_events.py deleted file mode 100644 index bf26b22..0000000 --- a/tests/test_events.py +++ /dev/null @@ -1,76 +0,0 @@ -from collections.abc import Iterator -from contextlib import contextmanager - -from sqlalchemy import create_engine -from sqlalchemy.orm import Session, sessionmaker - -from app.config import Settings -from app.database import Base -from app.models import Event -from app.services.events import get_or_create_default_event - - -def make_settings(**overrides) -> Settings: - defaults = dict( - telegram_token="token", - basic_auth_username="admin", - basic_auth_password="secret", - ) - defaults.update(overrides) - return Settings(**defaults) - - -@contextmanager -def make_session() -> Iterator[Session]: - engine = create_engine("sqlite+pysqlite:///:memory:", future=True) - TestingSession = sessionmaker( - bind=engine, autoflush=False, autocommit=False, expire_on_commit=False - ) - Base.metadata.create_all(engine) - session = TestingSession() - try: - yield session - finally: - session.close() - Base.metadata.drop_all(engine) - engine.dispose() - - -def test_event_created_with_attendee_limit(): - settings = make_settings(event_name="Test Event", attendee_limit=99) - with make_session() as session: - event = get_or_create_default_event(session, settings) - - assert event.id is not None - assert event.name == "Test Event" - assert event.capacity == 99 - - stored = session.get(Event, event.id) - assert stored is not None - assert stored.capacity == 99 - - -def test_existing_event_capacity_backfilled(): - settings = make_settings(event_name="Existing Event", attendee_limit=25) - with make_session() as session: - event = Event(name="Existing Event", capacity=None) - session.add(event) - session.flush() - - fetched = get_or_create_default_event(session, settings) - - assert fetched.id == event.id - assert fetched.capacity == 25 - - -def test_existing_event_capacity_preserved(): - settings = make_settings(event_name="Existing Event", attendee_limit=50) - with make_session() as session: - event = Event(name="Existing Event", capacity=75) - session.add(event) - session.flush() - - fetched = get_or_create_default_event(session, settings) - - assert fetched.id == event.id - assert fetched.capacity == 75 diff --git a/tests/test_handlers.py b/tests/test_handlers.py new file mode 100644 index 0000000..6e116fd --- /dev/null +++ b/tests/test_handlers.py @@ -0,0 +1,97 @@ +from app.models import UserStatus +from app.telebot import handlers + + +def test_status_text_mapping(): + assert handlers.status_text(UserStatus.NONE) == "Нет заявки" + assert handlers.status_text(UserStatus.PROCESSING) == "Заявка в обработке" + assert handlers.status_text(UserStatus.ATTENDEE) == "Участник" + assert handlers.status_text(UserStatus.WAITLIST) == "Лист ожидания" + + +def test_notifications_text_variants(): + enabled = handlers.notifications_text(True) + disabled = handlers.notifications_text(False) + + assert enabled.startswith("[Включены]") + assert disabled.startswith("[Выключены]") + + +def test_build_main_keyboard_dynamic_button(): + keyboard = handlers.build_main_keyboard(UserStatus.NONE, event_started=False) + assert keyboard.keyboard[0][0].text == handlers.MENU_APPLICATION + + keyboard = handlers.build_main_keyboard(UserStatus.PROCESSING, event_started=False) + assert keyboard.keyboard[0][0].text == handlers.MENU_CANCEL + + keyboard = handlers.build_main_keyboard(UserStatus.ATTENDEE, event_started=True) + assert keyboard.keyboard[0][0].text == handlers.MENU_FEEDBACK + + +def test_parse_username_handles_none_and_prefix(): + assert handlers.parse_username(None) is None + assert handlers.parse_username("@Admin ") == "Admin" + + +def test_is_admin_respects_settings(monkeypatch): + monkeypatch.setenv("TELEGRAM_TOKEN", "token") + monkeypatch.setenv("ADMIN_USERNAMES", "Admin, @Owner") + handlers.get_settings.cache_clear() + + assert handlers.is_admin("@admin") is True + assert handlers.is_admin("owner") is True + assert handlers.is_admin("someone") is False + + +class _DummyChat: + def __init__(self) -> None: + self.sent_messages: list[str] = [] + + async def send_message(self, text: str) -> None: + self.sent_messages.append(text) + + +class _DummyUser: + def __init__(self, username: str | None) -> None: + self.username = username + + +class _DummyUpdate: + def __init__(self, username: str | None, chat: _DummyChat | None = None) -> None: + self.effective_user = _DummyUser(username) if username else None + self.effective_chat = chat + + +def test_ensure_admin_sends_denied_message(monkeypatch): + monkeypatch.setenv("TELEGRAM_TOKEN", "token") + monkeypatch.setenv("ADMIN_USERNAMES", "admin") + handlers.get_settings.cache_clear() + + chat = _DummyChat() + update = _DummyUpdate(username="guest", chat=chat) + created: list[object] = [] + + def fake_create_task(coro): + created.append(coro) + import asyncio + + asyncio.run(coro) + return None + + monkeypatch.setattr(handlers.asyncio, "create_task", fake_create_task) + + assert handlers.ensure_admin(update) is False + assert created + assert chat.sent_messages == ["Unknown command or has no permission"] + + +def test_ensure_admin_allows_admin(monkeypatch): + monkeypatch.setenv("TELEGRAM_TOKEN", "token") + monkeypatch.setenv("ADMIN_USERNAMES", "admin") + handlers.get_settings.cache_clear() + + chat = _DummyChat() + update = _DummyUpdate(username="admin", chat=chat) + + assert handlers.ensure_admin(update) is True + assert chat.sent_messages == [] diff --git a/tests/test_messaging.py b/tests/test_messaging.py deleted file mode 100644 index 6d7b40a..0000000 --- a/tests/test_messaging.py +++ /dev/null @@ -1,46 +0,0 @@ -import asyncio -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from telegram.error import TelegramError - -from app.services import messaging - - -class FakeResult: - def __init__(self, users): - self._users = users - - def scalars(self): - return self - - def all(self): - return self._users - - -class FakeSession: - def __init__(self, users): - self._users = users - self.executed = [] - - def execute(self, statement): - self.executed.append(statement) - return FakeResult(self._users) - - -def test_broadcast_message_delivers_and_unsubscribes(): - users = [ - SimpleNamespace(id=1, telegram_id=100, is_subscribed=True), - SimpleNamespace(id=2, telegram_id=None, is_subscribed=True), - SimpleNamespace(id=3, telegram_id=200, is_subscribed=True), - ] - session = FakeSession(users) - bot = SimpleNamespace(send_message=AsyncMock(side_effect=[None, TelegramError("failed")])) - - delivered = asyncio.run(messaging.broadcast_message(session, bot, "Hello")) - - assert delivered == 1 - assert users[0].is_subscribed is True - assert users[1].is_subscribed is True - assert users[2].is_subscribed is False - assert bot.send_message.await_count == 2 diff --git a/tests/test_posts.py b/tests/test_posts.py deleted file mode 100644 index f4cb256..0000000 --- a/tests/test_posts.py +++ /dev/null @@ -1,76 +0,0 @@ -import asyncio -from datetime import datetime, timedelta, timezone -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from telegram.error import TelegramError - -from app.models import ScheduledPost -from app.services import posts -from tests.test_events import make_session - - -class StubSession: - def __init__(self): - self.added: list[ScheduledPost] = [] - - def add(self, obj): - self.added.append(obj) - - def flush(self): - for index, obj in enumerate(self.added, start=1): - obj.id = index - - -def test_schedule_post_normalizes_timezone(): - session = StubSession() - send_at = datetime(2024, 5, 1, 12, 0, 0) - - post = posts.schedule_post(session, title="Update", content="Body", send_at=send_at) - - assert post.id == 1 - assert post.send_at.tzinfo is timezone.utc - - -def test_get_pending_posts_filters_by_time(): - with make_session() as session: - now = datetime(2024, 5, 1, 13, 0, tzinfo=timezone.utc) - - ready = ScheduledPost(title="Ready", content="Soon", send_at=now - timedelta(minutes=5)) - upcoming = ScheduledPost(title="Later", content="Later", send_at=now + timedelta(hours=1)) - - session.add_all([ready, upcoming]) - session.commit() - - pending = posts.get_pending_posts(session, now=now) - - assert [p.title for p in pending] == ["Ready"] - - -def test_broadcast_post_marks_sent_and_unsubscribes(): - users = [ - SimpleNamespace(id=1, telegram_id=101, is_subscribed=True), - SimpleNamespace(id=2, telegram_id=202, is_subscribed=True), - ] - - class FakeResult: - def scalars(self): - return self - - def all(self): - return users - - class FakeSession: - def execute(self, statement): - self.statement = statement - return FakeResult() - - session = FakeSession() - bot = SimpleNamespace(send_message=AsyncMock(side_effect=[None, TelegramError("oops")])) - post = SimpleNamespace(id=5, title="Title", content="Content", sent_at=None) - - asyncio.run(posts.broadcast_post(session, bot, post)) - - assert post.sent_at is not None - assert users[0].is_subscribed is True - assert users[1].is_subscribed is False diff --git a/tests/test_registrations.py b/tests/test_registrations.py deleted file mode 100644 index dc07efa..0000000 --- a/tests/test_registrations.py +++ /dev/null @@ -1,127 +0,0 @@ -import pytest - -from app.models import Event, Registration, RegistrationCategory, RegistrationStatus, User -from app.services import registrations -from tests.test_events import make_session - - -def create_user(session, *, telegram_id: int, name: str) -> User: - user = User( - telegram_id=telegram_id, - username=name, - first_name=name, - last_name=None, - display_name=name, - contact=name, - is_manual=False, - ) - session.add(user) - session.flush() - return user - - -def create_event(session, *, capacity: int | None) -> Event: - event = Event(name="Test", capacity=capacity) - session.add(event) - session.flush() - return event - - -def approve_registration(session, registration: Registration) -> None: - registration.status = RegistrationStatus.APPROVED - session.add(registration) - session.flush() - - -def test_register_user_waitlists_when_full(): - with make_session() as session: - event = create_event(session, capacity=1) - existing_user = create_user(session, telegram_id=1, name="existing") - new_user = create_user(session, telegram_id=2, name="new") - - existing = registrations.register_user( - session, - event=event, - user=existing_user, - category=RegistrationCategory.ATTENDEE, - ) - approve_registration(session, existing.registration) - - result = registrations.register_user( - session, - event=event, - user=new_user, - category=RegistrationCategory.ATTENDEE, - ) - - assert result.created is True - assert result.waitlisted is True - assert result.registration.status == RegistrationStatus.WAITLISTED - - -def test_update_registration_status_enforces_capacity(): - with make_session() as session: - event = create_event(session, capacity=1) - user_one = create_user(session, telegram_id=1, name="one") - user_two = create_user(session, telegram_id=2, name="two") - - reg_one = registrations.register_user( - session, - event=event, - user=user_one, - category=RegistrationCategory.ATTENDEE, - ).registration - reg_two = registrations.register_user( - session, - event=event, - user=user_two, - category=RegistrationCategory.ATTENDEE, - ).registration - - registrations.update_registration_status( - session, - reg_one, - status=RegistrationStatus.APPROVED, - ) - with pytest.raises(registrations.CapacityError): - registrations.update_registration_status( - session, - reg_two, - status=RegistrationStatus.APPROVED, - ) - - -def test_update_registration_status_allows_priority_override(): - with make_session() as session: - event = create_event(session, capacity=1) - primary = create_user(session, telegram_id=1, name="primary") - priority = create_user(session, telegram_id=2, name="priority") - - reg_primary = registrations.register_user( - session, - event=event, - user=primary, - category=RegistrationCategory.ATTENDEE, - ).registration - reg_priority = registrations.register_user( - session, - event=event, - user=priority, - category=RegistrationCategory.ATTENDEE, - ).registration - - registrations.update_registration_status( - session, - reg_primary, - status=RegistrationStatus.APPROVED, - ) - - registrations.update_registration_status( - session, - reg_priority, - status=RegistrationStatus.APPROVED, - is_priority=True, - ) - - assert reg_priority.is_priority is True - assert reg_priority.status == RegistrationStatus.APPROVED diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py deleted file mode 100644 index bb6b4df..0000000 --- a/tests/test_scheduler.py +++ /dev/null @@ -1,110 +0,0 @@ -import asyncio -from types import SimpleNamespace - -from app import scheduler - - -class DummySessionScope: - def __init__(self, session): - self._session = session - self.entered = False - self.exited = False - - def __call__(self): - return self - - def __enter__(self): - self.entered = True - return self._session - - def __exit__(self, exc_type, exc, tb): - self.exited = True - return False - - -def test_process_scheduled_posts_no_pending(monkeypatch): - session = object() - scope = DummySessionScope(session) - monkeypatch.setattr(scheduler, "session_scope", scope) - monkeypatch.setattr(scheduler, "get_pending_posts", lambda _session: []) - broadcast_calls: list[int] = [] - - async def fake_broadcast(session_arg, bot_arg, post_arg): - broadcast_calls.append(post_arg.id) - - monkeypatch.setattr(scheduler, "broadcast_post", fake_broadcast) - - asyncio.run(scheduler.process_scheduled_posts(bot=object())) - - assert scope.entered and scope.exited - assert broadcast_calls == [] - - -def test_process_scheduled_posts_handles_errors(monkeypatch): - session = object() - scope = DummySessionScope(session) - monkeypatch.setattr(scheduler, "session_scope", scope) - - posts = [SimpleNamespace(id=1), SimpleNamespace(id=2)] - monkeypatch.setattr(scheduler, "get_pending_posts", lambda _session: posts) - - calls: list[int] = [] - - async def fake_broadcast(_session, _bot, post): - calls.append(post.id) - if post.id == 1: - raise RuntimeError("boom") - - monkeypatch.setattr(scheduler, "broadcast_post", fake_broadcast) - - asyncio.run(scheduler.process_scheduled_posts(bot=object())) - - assert calls == [1, 2] - - -def test_start_and_stop_scheduler(monkeypatch): - recorded_loop = object() - monkeypatch.setattr(scheduler.asyncio, "get_running_loop", lambda: recorded_loop) - - scheduled_jobs: list[dict[str, object]] = [] - - class DummyScheduler: - def __init__(self, *, event_loop): - self.event_loop = event_loop - self.started = False - self.shutdown_called = None - - def add_job(self, func, trigger, seconds): - scheduled_jobs.append({"func": func, "trigger": trigger, "seconds": seconds}) - - def start(self): - self.started = True - - def shutdown(self, wait): - self.shutdown_called = wait - - monkeypatch.setattr(scheduler, "AsyncIOScheduler", DummyScheduler) - - called = {} - - async def fake_process(bot): - called["value"] = bot - - monkeypatch.setattr(scheduler, "process_scheduled_posts", fake_process) - - dummy_bot = object() - settings = SimpleNamespace(scheduler_interval_seconds=30, timezone="UTC") - - sched = scheduler.start_scheduler(settings, dummy_bot) - - assert isinstance(sched, DummyScheduler) - assert sched.event_loop is recorded_loop - assert sched.started - assert len(scheduled_jobs) == 1 - - job_func = scheduled_jobs[0]["func"] - asyncio.run(job_func()) - assert called["value"] is dummy_bot - - scheduler.stop_scheduler(sched) - assert sched.shutdown_called is False diff --git a/tests/test_users.py b/tests/test_users.py deleted file mode 100644 index d6a07f5..0000000 --- a/tests/test_users.py +++ /dev/null @@ -1,58 +0,0 @@ -from types import SimpleNamespace - -from app.models import User -from app.services import users -from tests.test_events import make_session - - -def test_resolve_display_name_prefers_full_name(): - tg_user = SimpleNamespace(full_name="Full Name", username="user", id=1) - assert users.resolve_display_name(tg_user) == "Full Name" - - -def test_get_or_create_user_updates_existing(): - with make_session() as session: - existing = User( - telegram_id=1, - username="old", - first_name="Old", - last_name=None, - display_name="Old", - contact="old", - is_manual=False, - ) - session.add(existing) - session.commit() - - tg_user = SimpleNamespace( - id=1, - username="new", - first_name="New", - last_name="Name", - full_name="New Name", - ) - - updated = users.get_or_create_user(session, tg_user) - - assert updated.id == existing.id - assert updated.username == "new" - assert updated.display_name == "New Name" - assert updated.contact == "new" - assert updated.is_subscribed is True - - -def test_get_or_create_user_creates_new_user(): - with make_session() as session: - tg_user = SimpleNamespace( - id=2, - username="fresh", - first_name="Fresh", - last_name=None, - full_name=None, - ) - - created = users.get_or_create_user(session, tg_user) - - assert created.id is not None - assert created.display_name == "fresh" - assert created.is_manual is False diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..a992539 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,18 @@ +from app import config +from app import const +from app import utils + + +def test_resolve_from_directory_prefers_first_filename(tmp_path): + first = tmp_path / const.CONFIG_FILENAMES[0] + second = tmp_path / const.CONFIG_FILENAMES[1] + second.write_text("telegram_token: second") + first.write_text("telegram_token: first") + + assert utils.resolve_from_directory(tmp_path) == first + + +def test_load_from_yaml_returns_empty_when_missing(monkeypatch): + monkeypatch.setattr(config, "config_path", lambda: None) + + assert config.Settings.load_from_yaml() == {} From 20ae39eb5d018e1deead0bf44ba388de46b31d03 Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Fri, 26 Dec 2025 23:17:23 +0500 Subject: [PATCH 06/15] templates|app: move all the strings into locales --- app/config.py | 1 + app/locales/en.json | 268 ++++++++++++++++++++++++++++++ app/telebot/handlers.py | 203 +++++++++++++++------- app/web/admin.py | 228 ++++++++++++++----------- templates/base.html | 22 +-- templates/dashboard.html | 42 ++--- templates/posts.html | 42 ++--- templates/registrations_list.html | 46 ++--- templates/urgent.html | 8 +- 9 files changed, 619 insertions(+), 241 deletions(-) diff --git a/app/config.py b/app/config.py index 09d5adb..434e2b9 100644 --- a/app/config.py +++ b/app/config.py @@ -11,6 +11,7 @@ class Settings(BaseSettings): telegram_token: str = Field(..., env="TELEGRAM_TOKEN") admin_usernames: List[str] = Field(default_factory=list, env="ADMIN_USERNAMES") + locale: str = Field(default="en", env="LOCALE") database_url: str = Field( default="sqlite:///./anonchatbot.db", diff --git a/app/locales/en.json b/app/locales/en.json index d829f64..ddeab13 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -44,5 +44,273 @@ "approved_priority": "Great news! You've been approved for the event.", "rejected": "Unfortunately we can't confirm your spot this time.", "waitlisted": "You're still on the waiting list. We'll keep you updated." + }, + "bot": { + "menu": { + "application": "Заявка", + "cancel": "Отмена заявки", + "feedback": "Отзыв", + "schedule": "Афиша", + "status": "Статус", + "notifications": "Нотификации", + "home": "На главную" + }, + "notifications": { + "status": { + "enabled": "Включены", + "disabled": "Выключены" + }, + "message": "[{status}] Бот будет присылать важные уведомления, когда админы их отправят (в т.ч. можно заранее запланировать в Telegram). Рекомендуем оставить включенными. Отключение: /notifications_disable, включение: /notifications_enable." + }, + "status": { + "none": "Нет заявки", + "processing": "Заявка в обработке", + "attendee": "Участник", + "waitlist": "Лист ожидания", + "attendee_notification": "Статус обновлен: участник. Будем рады видеть вас на мероприятии. Подробнее можно узнать в Афише." + }, + "templates": { + "missing_welcome": "No message template: welcome_message", + "missing_schedule": "No message template: schedule_message" + }, + "messages": { + "main_menu": "Главное меню", + "event_started_broadcast": "Мероприятие началось! Следите за обновлениями в боте." + }, + "application": { + "already_created": "Заявка уже создана.", + "ask_full_name": "Как вас зовут? (Имя Фамилия)", + "ask_job": "Кем и где вы работаете? (позиция, компания)", + "ask_career": "Какой путь... (1)... (2)... В ответ напишите цифру", + "confirmation": "Спасибо за вашу заявку! Ожидайте новостей по мероприятию.", + "cancelled": "Заявка отменена." + }, + "feedback": { + "prompt": "Вы можете оставить отзыв на последнее мероприятие, в котором принимали участие. Отправьте одно сообщение в свободной форме. Мы будем рады развернутому отзыву по ссылке: <ссылка>", + "confirmation": "Спасибо за ваш отзыв!" + }, + "admin": { + "errors": { + "expected_csv": "Ожидается CSV-файл.", + "unknown_or_forbidden": "Unknown command or has no permission", + "unknown": "Unknown command", + "nickname_required": "Нужен nickname.", + "user_not_found": "Пользователь не найден.", + "user_id_required": "Нужен user_id.", + "user_id_invalid": "Неверный user_id.", + "event_id_required": "Нужен id." + }, + "templates": { + "saved_welcome": "welcome_message сохранено.", + "saved_schedule": "schedule_message сохранено.", + "awaiting_welcome": "Ожидаю следующее сообщение для welcome_message.", + "awaiting_schedule": "Ожидаю следующее сообщение для schedule_message." + }, + "broadcast": { + "awaiting_message": "Waiting for the notification message", + "awaiting_all": "Ожидаю следующее сообщение для рассылки всем.", + "awaiting_attendees": "Ожидаю следующее сообщение для рассылки участникам." + }, + "database": { + "updated": "База данных обновлена.", + "awaiting_upload": "Ожидаю CSV-файл с пользователями." + }, + "commands_list": "Команды:\n{commands}", + "applications": { + "summary": "applications: {applications} | attendee: {attendees}" + }, + "status": { + "updated": "Статус обновлен." + }, + "event_started": "event_started=true", + "event_cancelled": "event_started=false", + "event_id_updated": "event_id обновлен." + } + }, + "admin": { + "title": "Admin", + "nav": { + "brand": "Event admin", + "dashboard": "Dashboard", + "posts": "Scheduled posts", + "registrations": "Registrations", + "pending": "Pending", + "approved": "Approved", + "priority": "Priority", + "waitlisted": "Waitlisted", + "declined": "Declined", + "urgent": "Urgent message" + }, + "dashboard": { + "title": "Event overview", + "subtitle": "Quick snapshot of attendance, capacity and scheduled communications.", + "timezone_label": "Timezone: {timezone}", + "timezone_warning": "Changes are kept in memory only.", + "pending_review": "Pending review", + "current_limit": "Current limit", + "no_limit": "No limit", + "approved": "Approved", + "priority_approvals": "Priority approvals", + "waitlisted": "Waitlisted", + "declined": "Declined", + "summary_title": "Event summary", + "attendee_limit_label": "Attendee limit:", + "no_limit_set": "No limit set", + "waiting_to_process": "Waiting to be processed:", + "by_category": "Registrations by category", + "upcoming_posts": "Upcoming posts", + "upcoming_help": "All times are displayed in {timezone}.", + "no_upcoming_posts": "No upcoming posts scheduled." + }, + "posts": { + "title": "Scheduled posts for {event_name}", + "flash": { + "scheduled": "Scheduled new post", + "cancelled": "Post cancelled" + }, + "timezone": { + "title": "Timezone preferences", + "help": "All times below are displayed as {timezone}.", + "warning": "No config file detected – the choice will reset after restart.", + "label": "Timezone", + "save": "Save timezone" + }, + "new": { + "title": "Plan a new announcement", + "help": "Provide the local send time; we will handle the UTC conversion.", + "fields": { + "title": "Title", + "content": "Content", + "send_at": "Send at ({timezone})" + }, + "submit": "Schedule post" + }, + "upcoming": { + "title": "Upcoming posts", + "help": "Showing local time in {timezone}.", + "empty": "No posts scheduled yet." + }, + "sent": { + "title": "Recently sent", + "help": "Timestamps reflect {timezone}.", + "empty": "No posts sent yet.", + "empty_date": "—" + }, + "table": { + "title": "Title", + "scheduled": "Scheduled", + "send_at": "Send at", + "sent_at": "Sent at" + } + }, + "registrations": { + "pending": { + "title": "Pending registrations", + "table_title": "Waiting for review", + "empty": "No registrations are waiting for review." + }, + "approved": { + "title": "Approved registrations", + "table_title": "Approved attendees", + "empty": "No approved registrations yet." + }, + "priority": { + "title": "Priority approvals", + "table_title": "Approved with priority", + "empty": "No priority approvals yet." + }, + "waitlisted": { + "title": "Waitlisted registrations", + "table_title": "Waitlisted attendees", + "empty": "No waitlisted registrations." + }, + "declined": { + "title": "Declined registrations", + "table_title": "Declined applicants", + "empty": "No declined registrations." + }, + "capacity": { + "title": "Event capacity", + "approved": "Approved attendees:", + "no_limit": "(no limit)", + "pending": "Waiting to review:", + "view": "view", + "limit_label": "Attendee limit", + "limit_placeholder": "Leave blank for no limit", + "limit_help": "Leave blank to remove the limit.", + "save": "Save limit" + }, + "manual": { + "title": "Add attendee manually", + "display_name": "Display name", + "contact": "Contact info", + "contact_placeholder": "Email, phone, etc.", + "category": "Category", + "notes": "Notes", + "priority": "Priority attendee (ignore limit)", + "submit": "Add registration" + }, + "table": { + "name": "Name", + "category": "Category", + "status": "Status", + "priority": "Priority", + "notes": "Notes", + "actions": "Actions" + }, + "flash": { + "deleted": "Registration deleted", + "limit_removed": "Attendee limit removed", + "limit_updated": "Attendee limit updated" + } + }, + "urgent": { + "title": "Urgent notification", + "message_label": "Message to send everyone", + "send": "Send now", + "delivered": "Delivered to {delivered} users." + }, + "actions": { + "approve": "Approve", + "approve_priority": "Approve as priority", + "waitlist": "Waitlist", + "reject": "Reject", + "mark_priority": "Mark as priority", + "reset_pending": "Reset to pending", + "mark_regular": "Mark as regular", + "delete": "Delete" + }, + "labels": { + "yes": "Yes", + "no": "No" + }, + "categories": { + "attendee": "Attendee", + "lecturer": "Lecturer", + "showcase": "Project showcase" + }, + "statuses": { + "pending": "Pending", + "approved": "Approved", + "waitlisted": "Waitlisted", + "rejected": "Declined" + }, + "timezone": { + "updated": "Timezone updated to {timezone}{suffix}", + "not_saved_suffix": " (not saved to disk)" + }, + "errors": { + "invalid_date_format": "Invalid date format", + "timezone_required": "Timezone is required", + "invalid_timezone": "Invalid timezone", + "post_not_found": "Post not found", + "post_already_sent": "Cannot cancel a post that has already been sent", + "registration_not_found": "Registration not found", + "unknown_status": "Unknown status", + "attendee_limit_reached": "Attendee limit reached", + "unknown_category": "Unknown category", + "limit_integer": "Limit must be an integer", + "limit_non_negative": "Limit must be zero or greater" + } } } diff --git a/app/telebot/handlers.py b/app/telebot/handlers.py index fc246b0..7298f5f 100644 --- a/app/telebot/handlers.py +++ b/app/telebot/handlers.py @@ -17,6 +17,7 @@ from app.config import get_settings from app.database import session_scope +from app.localization import DEFAULT_LOCALE, get_localizer from app.models import ( AdminState, AdminStateType, @@ -31,13 +32,21 @@ logger = logging.getLogger(__name__) -MENU_APPLICATION = "Заявка" -MENU_CANCEL = "Отмена заявки" -MENU_FEEDBACK = "Отзыв" -MENU_SCHEDULE = "Афиша" -MENU_STATUS = "Статус" -MENU_NOTIFICATIONS = "Нотификации" -MENU_HOME = "На главную" +def get_bot_localizer(): + settings = get_settings() + locale = getattr(settings, "locale", DEFAULT_LOCALE) + return get_localizer(locale) + + +LOCALIZER = get_bot_localizer() + +MENU_APPLICATION = LOCALIZER.get("bot.menu.application") +MENU_CANCEL = LOCALIZER.get("bot.menu.cancel") +MENU_FEEDBACK = LOCALIZER.get("bot.menu.feedback") +MENU_SCHEDULE = LOCALIZER.get("bot.menu.schedule") +MENU_STATUS = LOCALIZER.get("bot.menu.status") +MENU_NOTIFICATIONS = LOCALIZER.get("bot.menu.notifications") +MENU_HOME = LOCALIZER.get("bot.menu.home") APPLICATION_FULL_NAME = 1 APPLICATION_JOB = 2 @@ -72,22 +81,21 @@ def home_keyboard() -> ReplyKeyboardMarkup: def notifications_text(enabled: bool) -> str: - status = "Включены" if enabled else "Выключены" - return ( - f"[{status}] Бот будет присылать важные уведомления, когда админы их отправят " - "(в т.ч. можно заранее запланировать в Telegram). Рекомендуем оставить " - "включенными. Отключение: /notifications_disable, включение: /notifications_enable." - ) + localizer = get_bot_localizer() + status_key = "bot.notifications.status.enabled" if enabled else "bot.notifications.status.disabled" + status = localizer.get(status_key) + return localizer.format("bot.notifications.message", status=status) def status_text(status: UserStatus) -> str: + localizer = get_bot_localizer() mapping = { - UserStatus.NONE: "Нет заявки", - UserStatus.PROCESSING: "Заявка в обработке", - UserStatus.ATTENDEE: "Участник", - UserStatus.WAITLIST: "Лист ожидания", + UserStatus.NONE: "bot.status.none", + UserStatus.PROCESSING: "bot.status.processing", + UserStatus.ATTENDEE: "bot.status.attendee", + UserStatus.WAITLIST: "bot.status.waitlist", } - return mapping[status] + return localizer.get(mapping[status]) def get_or_create_event_state(session) -> EventState: @@ -164,7 +172,10 @@ async def send_welcome_message(update: Update, template: MessageTemplate | None) if not update.effective_chat: return if not template: - await update.effective_chat.send_message("No message template: welcome_message") + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.templates.missing_welcome") + ) return await update.effective_chat.bot.copy_message( chat_id=update.effective_chat.id, @@ -177,7 +188,10 @@ async def send_schedule_message(update: Update, template: MessageTemplate | None if not update.effective_chat: return if not template: - await update.effective_chat.send_message("No message template: schedule_message") + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.templates.missing_schedule") + ) return await update.effective_chat.bot.copy_message( chat_id=update.effective_chat.id, @@ -197,10 +211,11 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: event_state = get_or_create_event_state(session) template = get_template(session, "welcome_message") + localizer = get_bot_localizer() await send_welcome_message(update, template) await context.bot.send_message( chat_id=chat.id, - text="Главное меню", + text=localizer.get("bot.messages.main_menu"), reply_markup=build_main_keyboard(db_user.status, event_state.event_started), ) @@ -275,14 +290,15 @@ async def application_start(update: Update, context: ContextTypes.DEFAULT_TYPE) return ConversationHandler.END with session_scope() as session: db_user, _ = upsert_user(session, user) + localizer = get_bot_localizer() if db_user.status != UserStatus.NONE: await update.effective_chat.send_message( - "Заявка уже создана.", + localizer.get("bot.application.already_created"), reply_markup=home_keyboard(), ) return ConversationHandler.END await update.effective_chat.send_message( - "Как вас зовут? (Имя Фамилия)", + localizer.get("bot.application.ask_full_name"), reply_markup=home_keyboard(), ) return APPLICATION_FULL_NAME @@ -292,7 +308,8 @@ async def application_full_name(update: Update, context: ContextTypes.DEFAULT_TY if not update.message or not update.message.text: return APPLICATION_FULL_NAME context.user_data["full_name"] = update.message.text.strip() - await update.message.reply_text("Кем и где вы работаете? (позиция, компания)") + localizer = get_bot_localizer() + await update.message.reply_text(localizer.get("bot.application.ask_job")) return APPLICATION_JOB @@ -300,7 +317,8 @@ async def application_job(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if not update.message or not update.message.text: return APPLICATION_JOB context.user_data["job"] = update.message.text.strip() - await update.message.reply_text("Какой путь... (1)... (2)... В ответ напишите цифру") + localizer = get_bot_localizer() + await update.message.reply_text(localizer.get("bot.application.ask_career")) return APPLICATION_CAREER @@ -319,8 +337,9 @@ async def application_career(update: Update, context: ContextTypes.DEFAULT_TYPE) db_user.career_path = career_path db_user.status = UserStatus.PROCESSING event_state = get_or_create_event_state(session) + localizer = get_bot_localizer() await chat.send_message( - "Спасибо за вашу заявку! Ожидайте новостей по мероприятию.", + localizer.get("bot.application.confirmation"), reply_markup=build_main_keyboard(db_user.status, event_state.event_started), ) context.user_data.clear() @@ -336,8 +355,9 @@ async def application_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) db_user, _ = upsert_user(session, user) event_state = get_or_create_event_state(session) context.user_data.clear() + localizer = get_bot_localizer() await chat.send_message( - "Главное меню", + localizer.get("bot.messages.main_menu"), reply_markup=build_main_keyboard(db_user.status, event_state.event_started), ) return ConversationHandler.END @@ -355,8 +375,9 @@ async def cancel_application(update: Update, context: ContextTypes.DEFAULT_TYPE) db_user.job = None db_user.career_path = None event_state = get_or_create_event_state(session) + localizer = get_bot_localizer() await chat.send_message( - "Заявка отменена.", + localizer.get("bot.application.cancelled"), reply_markup=build_main_keyboard(db_user.status, event_state.event_started), ) @@ -370,10 +391,11 @@ async def schedule(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: db_user, _ = upsert_user(session, user) event_state = get_or_create_event_state(session) template = get_template(session, "schedule_message") + localizer = get_bot_localizer() await send_schedule_message(update, template) await context.bot.send_message( chat_id=chat.id, - text="Главное меню", + text=localizer.get("bot.messages.main_menu"), reply_markup=build_main_keyboard(db_user.status, event_state.event_started), ) @@ -386,16 +408,15 @@ async def feedback_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> with session_scope() as session: db_user, _ = upsert_user(session, user) event_state = get_or_create_event_state(session) + localizer = get_bot_localizer() if not (event_state.event_started and db_user.status == UserStatus.ATTENDEE): await chat.send_message( - "Главное меню", + localizer.get("bot.messages.main_menu"), reply_markup=build_main_keyboard(db_user.status, event_state.event_started), ) return ConversationHandler.END await chat.send_message( - "Вы можете оставить отзыв на последнее мероприятие, в котором принимали участие. " - "Отправьте одно сообщение в свободной форме. Мы будем рады развернутому отзыву " - "по ссылке: <ссылка>", + localizer.get("bot.feedback.prompt"), reply_markup=home_keyboard(), ) return FEEDBACK_TEXT @@ -418,8 +439,9 @@ async def feedback_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i created_at=datetime.utcnow(), ) session.add(feedback) + localizer = get_bot_localizer() await chat.send_message( - "Спасибо за ваш отзыв!", + localizer.get("bot.feedback.confirmation"), reply_markup=build_main_keyboard(db_user.status, event_state.event_started), ) return ConversationHandler.END @@ -435,12 +457,10 @@ async def send_attendee_notification(bot, telegram_id: int) -> None: user = session.scalar(select(User).where(User.telegram_id == telegram_id)) if not user or user.status != UserStatus.ATTENDEE: return + localizer = get_bot_localizer() await bot.send_message( chat_id=telegram_id, - text=( - "Статус обновлен: участник. Будем рады видеть вас на мероприятии. " - "Подробнее можно узнать в Афише." - ), + text=localizer.get("bot.status.attendee_notification"), ) @@ -452,9 +472,10 @@ async def handle_admin_payload(update: Update, context: ContextTypes.DEFAULT_TYP state = get_admin_state(session, user.id) if not state: return + localizer = get_bot_localizer() if state.waiting_for == AdminStateType.UPLOAD_DB: if not update.message.document: - await update.message.reply_text("Ожидается CSV-файл.") + await update.message.reply_text(localizer.get("bot.admin.errors.expected_csv")) return await process_upload_database(update, context, state.admin_id) return @@ -462,17 +483,17 @@ async def handle_admin_payload(update: Update, context: ContextTypes.DEFAULT_TYP if state.waiting_for == AdminStateType.WELCOME: set_template(session, "welcome_message", message.chat_id, message.message_id) clear_admin_state(session, user.id) - await update.message.reply_text("welcome_message сохранено.") + await update.message.reply_text(localizer.get("bot.admin.templates.saved_welcome")) return if state.waiting_for == AdminStateType.SCHEDULE: set_template(session, "schedule_message", message.chat_id, message.message_id) clear_admin_state(session, user.id) - await update.message.reply_text("schedule_message сохранено.") + await update.message.reply_text(localizer.get("bot.admin.templates.saved_schedule")) return if state.waiting_for in (AdminStateType.BROADCAST_ALL, AdminStateType.BROADCAST_ATTENDEE): if message.text and message.text.startswith("/"): await update.message.reply_text( - "Waiting for the notification message" + localizer.get("bot.admin.broadcast.awaiting_message") ) return clear_admin_state(session, user.id) @@ -527,6 +548,18 @@ async def broadcast_payload(session, context, message, waiting_for: AdminStateTy logger.exception("Failed to send broadcast to %s", target.telegram_id) +async def broadcast_text(bot, text: str) -> None: + with session_scope() as session: + users = session.execute(select(User)).scalars().all() + for user in users: + if not user.telegram_id: + continue + try: + await bot.send_message(chat_id=user.telegram_id, text=text) + except Exception: + logger.exception("Failed to send broadcast to %s", user.telegram_id) + + async def process_upload_database( update: Update, context: ContextTypes.DEFAULT_TYPE, admin_id: int ) -> None: @@ -562,7 +595,8 @@ async def process_upload_database( if row.get("notifications_enabled") is not None: user.notifications_enabled = row["notifications_enabled"].lower() == "true" user.updated_at = datetime.utcnow() - await update.message.reply_text("База данных обновлена.") + localizer = get_bot_localizer() + await update.message.reply_text(localizer.get("bot.admin.database.updated")) def ensure_admin(update: Update) -> bool: @@ -570,7 +604,9 @@ def ensure_admin(update: Update) -> bool: if not user or not is_admin(user.username): if update.effective_chat: asyncio.create_task( - update.effective_chat.send_message("Unknown command or has no permission") + update.effective_chat.send_message( + get_bot_localizer().get("bot.admin.errors.unknown_or_forbidden") + ) ) return False return True @@ -598,7 +634,10 @@ async def admin_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None "/event_cancel", "/set_event_id {id}", ] - await update.effective_chat.send_message("Команды:\n" + "\n".join(commands)) + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.format("bot.admin.commands_list", commands="\n".join(commands)) + ) async def set_welcome_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -609,7 +648,10 @@ async def set_welcome_message(update: Update, context: ContextTypes.DEFAULT_TYPE return with session_scope() as session: set_admin_state(session, user.id, AdminStateType.WELCOME) - await update.effective_chat.send_message("Ожидаю следующее сообщение для welcome_message.") + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.templates.awaiting_welcome") + ) async def set_schedule_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -620,7 +662,10 @@ async def set_schedule_message(update: Update, context: ContextTypes.DEFAULT_TYP return with session_scope() as session: set_admin_state(session, user.id, AdminStateType.SCHEDULE) - await update.effective_chat.send_message("Ожидаю следующее сообщение для schedule_message.") + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.templates.awaiting_schedule") + ) async def urgent_notification(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -631,7 +676,10 @@ async def urgent_notification(update: Update, context: ContextTypes.DEFAULT_TYPE return with session_scope() as session: set_admin_state(session, user.id, AdminStateType.BROADCAST_ALL) - await update.effective_chat.send_message("Ожидаю следующее сообщение для рассылки всем.") + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.broadcast.awaiting_all") + ) async def urgent_notification_attendee(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -642,7 +690,10 @@ async def urgent_notification_attendee(update: Update, context: ContextTypes.DEF return with session_scope() as session: set_admin_state(session, user.id, AdminStateType.BROADCAST_ATTENDEE) - await update.effective_chat.send_message("Ожидаю следующее сообщение для рассылки участникам.") + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.broadcast.awaiting_attendees") + ) async def upload_database(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -653,7 +704,8 @@ async def upload_database(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return with session_scope() as session: set_admin_state(session, user.id, AdminStateType.UPLOAD_DB) - await update.effective_chat.send_message("Ожидаю CSV-файл с пользователями.") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.database.awaiting_upload")) async def download_database(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -727,7 +779,14 @@ async def check_applications(update: Update, context: ContextTypes.DEFAULT_TYPE) users = session.execute(select(User)).scalars().all() applications = [user for user in users if user.status != UserStatus.NONE] attendee_count = len([user for user in users if user.status == UserStatus.ATTENDEE]) - lines = [f"applications: {len(applications)} | attendee: {attendee_count}"] + localizer = get_bot_localizer() + lines = [ + localizer.format( + "bot.admin.applications.summary", + applications=len(applications), + attendees=attendee_count, + ) + ] for user in applications: label = f"@{user.username}" if user.username else str(user.telegram_id) lines.append(f"{label} -> {user.status.value}") @@ -749,19 +808,22 @@ async def update_status_by_username( return parts = update.message.text.split(maxsplit=1) if len(parts) < 2: - await update.effective_chat.send_message("Нужен nickname.") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.errors.nickname_required")) return nickname = parse_username(parts[1]) with session_scope() as session: user = session.scalar(select(User).where(User.username.ilike(nickname))) if not user: - await update.effective_chat.send_message("Пользователь не найден.") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.errors.user_not_found")) return user.status = status user.updated_at = datetime.utcnow() if status == UserStatus.ATTENDEE: asyncio.create_task(send_attendee_notification(context.bot, user.telegram_id)) - await update.effective_chat.send_message("Статус обновлен.") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.status.updated")) async def update_status_by_id( @@ -773,23 +835,27 @@ async def update_status_by_id( return parts = update.message.text.split(maxsplit=1) if len(parts) < 2: - await update.effective_chat.send_message("Нужен user_id.") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.errors.user_id_required")) return try: user_id = int(parts[1]) except ValueError: - await update.effective_chat.send_message("Неверный user_id.") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.errors.user_id_invalid")) return with session_scope() as session: user = session.scalar(select(User).where(User.telegram_id == user_id)) if not user: - await update.effective_chat.send_message("Пользователь не найден.") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.errors.user_not_found")) return user.status = status user.updated_at = datetime.utcnow() if status == UserStatus.ATTENDEE: asyncio.create_task(send_attendee_notification(context.bot, user.telegram_id)) - await update.effective_chat.send_message("Статус обновлен.") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.status.updated")) async def event_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -798,7 +864,10 @@ async def event_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non with session_scope() as session: state = get_or_create_event_state(session) state.event_started = True - await update.effective_chat.send_message("event_started=true") + localizer = get_bot_localizer() + if update.effective_chat: + await update.effective_chat.send_message(localizer.get("bot.admin.event_started")) + await broadcast_text(context.bot, localizer.get("bot.messages.event_started_broadcast")) async def event_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -807,7 +876,9 @@ async def event_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No with session_scope() as session: state = get_or_create_event_state(session) state.event_started = False - await update.effective_chat.send_message("event_started=false") + localizer = get_bot_localizer() + if update.effective_chat: + await update.effective_chat.send_message(localizer.get("bot.admin.event_cancelled")) async def set_event_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -817,12 +888,14 @@ async def set_event_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No return parts = update.message.text.split(maxsplit=1) if len(parts) < 2: - await update.effective_chat.send_message("Нужен id.") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.errors.event_id_required")) return with session_scope() as session: state = get_or_create_event_state(session) state.current_event_id = parts[1] - await update.effective_chat.send_message("event_id обновлен.") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.event_id_updated")) async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -830,9 +903,11 @@ async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if not user or not update.effective_chat: return if not is_admin(user.username): - await update.effective_chat.send_message("Unknown command or has no permission") + await update.effective_chat.send_message( + get_bot_localizer().get("bot.admin.errors.unknown_or_forbidden") + ) else: - await update.effective_chat.send_message("Unknown command") + await update.effective_chat.send_message(get_bot_localizer().get("bot.admin.errors.unknown")) def register(application: Application) -> None: diff --git a/app/web/admin.py b/app/web/admin.py index a2a61f9..ddaf4a4 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -42,6 +42,9 @@ def create_app(settings: Settings, *, bot) -> FastAPI: def current_localizer(): return get_localizer(app.state.settings.locale) + def template_context(request: Request, **kwargs: object) -> dict[str, object]: + return {"request": request, "localizer": current_localizer(), **kwargs} + def admin_auth( request: Request, credentials: HTTPBasicCredentials = Depends(security), @@ -145,16 +148,16 @@ async def dashboard( } return templates.TemplateResponse( "dashboard.html", - { - "request": request, - "event": event, - "status_summary": status_summary, - "category_summary": category_summary, - "upcoming": upcoming_view, - "timezone": settings.timezone, - "timezone_persisted": settings.can_persist_timezone, - "flash": request.query_params.get("msg"), - }, + template_context( + request, + event=event, + status_summary=status_summary, + category_summary=category_summary, + upcoming=upcoming_view, + timezone=settings.timezone, + timezone_persisted=settings.can_persist_timezone, + flash=request.query_params.get("msg"), + ), ) @app.get("/admin/posts") @@ -200,15 +203,15 @@ async def posts( ] return templates.TemplateResponse( "posts.html", - { - "request": request, - "upcoming": upcoming_view, - "sent": sent_view, - "event_name": settings.event_name, - "timezone": settings.timezone, - "timezone_persisted": settings.can_persist_timezone, - "flash": request.query_params.get("msg"), - }, + template_context( + request, + upcoming=upcoming_view, + sent=sent_view, + event_name=settings.event_name, + timezone=settings.timezone, + timezone_persisted=settings.can_persist_timezone, + flash=request.query_params.get("msg"), + ), ) @app.post("/admin/posts") @@ -220,11 +223,13 @@ async def create_post( _: str = Depends(admin_auth), session: Session = Depends(get_session), ): + localizer = current_localizer() try: send_at_dt = datetime.fromisoformat(send_at) except ValueError as exc: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date format" + status_code=status.HTTP_400_BAD_REQUEST, + detail=localizer.get("admin.errors.invalid_date_format"), ) from exc if send_at_dt.tzinfo is None: send_at_dt = send_at_dt.replace(tzinfo=settings.tzinfo) @@ -235,7 +240,9 @@ async def create_post( content=content, send_at=send_at_dt.astimezone(timezone.utc), ) - return redirect_with_message("/admin/posts", "Scheduled new post") + return redirect_with_message( + "/admin/posts", localizer.get("admin.posts.flash.scheduled") + ) @app.post("/admin/settings/timezone") async def update_timezone( @@ -243,11 +250,12 @@ async def update_timezone( return_to: str | None = Form(None), _: str = Depends(admin_auth), ): + localizer = current_localizer() normalized = timezone_value.strip() if not normalized: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Timezone is required", + detail=localizer.get("admin.errors.timezone_required"), ) try: @@ -255,16 +263,17 @@ async def update_timezone( except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), + detail=localizer.get("admin.errors.invalid_timezone"), ) from exc suffix = "" if not persisted: - suffix = " (not saved to disk)" + suffix = localizer.get("admin.timezone.not_saved_suffix") redirect_path = safe_redirect_path(return_to, "/admin") return redirect_with_message( - redirect_path, f"Timezone updated to {normalized}{suffix}" + redirect_path, + localizer.format("admin.timezone.updated", timezone=normalized, suffix=suffix), ) @app.post("/admin/posts/{post_id}/cancel") @@ -274,27 +283,34 @@ async def cancel_post( _: str = Depends(admin_auth), session: Session = Depends(get_session), ): + localizer = current_localizer() post = session.get(ScheduledPost, post_id) if not post: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=localizer.get("admin.errors.post_not_found"), + ) if post.sent_at is not None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot cancel a post that has already been sent", + detail=localizer.get("admin.errors.post_already_sent"), ) session.delete(post) session.flush() - return redirect_with_message("/admin/posts", "Post cancelled") + return redirect_with_message( + "/admin/posts", localizer.get("admin.posts.flash.cancelled") + ) def make_action( - label: str, + label_key: str, status_value: str, *, priority: bool | None = None, css_class: str | None = None, ) -> dict[str, str | bool | None]: + localizer = current_localizer() return { - "label": label, + "label": localizer.get(label_key), "status": status_value, "priority": priority, "class": css_class or "", @@ -341,60 +357,68 @@ def render_registration_page( ) return templates.TemplateResponse( "registrations_list.html", - { - "request": request, - "title": title, - "table_title": table_title, - "registrations": registration_views, - "empty_message": empty_message, - "show_manual_form": show_manual_form, - "allow_delete": allow_delete, - "return_to": request.url.path, - "event": event, - "approved_attendee_count": get_approved_attendee_count(session, event), - "pending_count": pending_count, - "flash": request.query_params.get("msg"), - }, + template_context( + request, + title=title, + table_title=table_title, + registrations=registration_views, + empty_message=empty_message, + show_manual_form=show_manual_form, + allow_delete=allow_delete, + return_to=request.url.path, + event=event, + approved_attendee_count=get_approved_attendee_count(session, event), + pending_count=pending_count, + flash=request.query_params.get("msg"), + ), ) def pending_actions(_: Registration) -> list[dict[str, str | bool | None]]: return [ - make_action("Approve", "approved", priority=False), - make_action("Approve as priority", "approved", priority=True, css_class="secondary"), - make_action("Waitlist", "waitlisted", css_class="secondary"), - make_action("Reject", "rejected", css_class="danger"), + make_action("admin.actions.approve", "approved", priority=False), + make_action( + "admin.actions.approve_priority", "approved", priority=True, css_class="secondary" + ), + make_action("admin.actions.waitlist", "waitlisted", css_class="secondary"), + make_action("admin.actions.reject", "rejected", css_class="danger"), ] def approved_actions(_: Registration) -> list[dict[str, str | bool | None]]: return [ - make_action("Mark as priority", "approved", priority=True, css_class="secondary"), - make_action("Waitlist", "waitlisted", css_class="secondary"), - make_action("Reject", "rejected", css_class="danger"), - make_action("Reset to pending", "pending", css_class="secondary"), + make_action( + "admin.actions.mark_priority", "approved", priority=True, css_class="secondary" + ), + make_action("admin.actions.waitlist", "waitlisted", css_class="secondary"), + make_action("admin.actions.reject", "rejected", css_class="danger"), + make_action("admin.actions.reset_pending", "pending", css_class="secondary"), ] def approved_priority_actions(_: Registration) -> list[dict[str, str | bool | None]]: return [ - make_action("Mark as regular", "approved", priority=False), - make_action("Waitlist", "waitlisted", css_class="secondary"), - make_action("Reject", "rejected", css_class="danger"), - make_action("Reset to pending", "pending", css_class="secondary"), + make_action("admin.actions.mark_regular", "approved", priority=False), + make_action("admin.actions.waitlist", "waitlisted", css_class="secondary"), + make_action("admin.actions.reject", "rejected", css_class="danger"), + make_action("admin.actions.reset_pending", "pending", css_class="secondary"), ] def waitlisted_actions(_: Registration) -> list[dict[str, str | bool | None]]: return [ - make_action("Approve", "approved", priority=False), - make_action("Approve as priority", "approved", priority=True, css_class="secondary"), - make_action("Reject", "rejected", css_class="danger"), - make_action("Reset to pending", "pending", css_class="secondary"), + make_action("admin.actions.approve", "approved", priority=False), + make_action( + "admin.actions.approve_priority", "approved", priority=True, css_class="secondary" + ), + make_action("admin.actions.reject", "rejected", css_class="danger"), + make_action("admin.actions.reset_pending", "pending", css_class="secondary"), ] def declined_actions(_: Registration) -> list[dict[str, str | bool | None]]: return [ - make_action("Approve", "approved", priority=False), - make_action("Approve as priority", "approved", priority=True, css_class="secondary"), - make_action("Waitlist", "waitlisted", css_class="secondary"), - make_action("Reset to pending", "pending", css_class="secondary"), + make_action("admin.actions.approve", "approved", priority=False), + make_action( + "admin.actions.approve_priority", "approved", priority=True, css_class="secondary" + ), + make_action("admin.actions.waitlist", "waitlisted", css_class="secondary"), + make_action("admin.actions.reset_pending", "pending", css_class="secondary"), ] def base_registration_query(event, status_filter): @@ -413,6 +437,7 @@ async def registrations_pending( _: str = Depends(admin_auth), session: Session = Depends(get_session), ): + localizer = current_localizer() event = get_or_create_default_event(session, settings) registrations = ( session.execute( @@ -426,9 +451,9 @@ async def registrations_pending( session, event=event, registrations=registrations, - title="Pending registrations", - table_title="Waiting for review", - empty_message="No registrations are waiting for review.", + title=localizer.get("admin.registrations.pending.title"), + table_title=localizer.get("admin.registrations.pending.table_title"), + empty_message=localizer.get("admin.registrations.pending.empty"), actions_builder=pending_actions, show_manual_form=True, allow_delete=True, @@ -440,6 +465,7 @@ async def registrations_approved( _: str = Depends(admin_auth), session: Session = Depends(get_session), ): + localizer = current_localizer() event = get_or_create_default_event(session, settings) base_query = base_registration_query( event, Registration.status == RegistrationStatus.APPROVED @@ -450,9 +476,9 @@ async def registrations_approved( session, event=event, registrations=registrations, - title="Approved registrations", - table_title="Approved attendees", - empty_message="No approved registrations yet.", + title=localizer.get("admin.registrations.approved.title"), + table_title=localizer.get("admin.registrations.approved.table_title"), + empty_message=localizer.get("admin.registrations.approved.empty"), actions_builder=approved_actions, ) @@ -462,6 +488,7 @@ async def registrations_approved_priority( _: str = Depends(admin_auth), session: Session = Depends(get_session), ): + localizer = current_localizer() event = get_or_create_default_event(session, settings) base_query = base_registration_query( event, Registration.status == RegistrationStatus.APPROVED @@ -472,9 +499,9 @@ async def registrations_approved_priority( session, event=event, registrations=registrations, - title="Priority approvals", - table_title="Approved with priority", - empty_message="No priority approvals yet.", + title=localizer.get("admin.registrations.priority.title"), + table_title=localizer.get("admin.registrations.priority.table_title"), + empty_message=localizer.get("admin.registrations.priority.empty"), actions_builder=approved_priority_actions, ) @@ -484,6 +511,7 @@ async def registrations_waitlisted( _: str = Depends(admin_auth), session: Session = Depends(get_session), ): + localizer = current_localizer() event = get_or_create_default_event(session, settings) registrations = ( session.execute( @@ -497,9 +525,9 @@ async def registrations_waitlisted( session, event=event, registrations=registrations, - title="Waitlisted registrations", - table_title="Waitlisted attendees", - empty_message="No waitlisted registrations.", + title=localizer.get("admin.registrations.waitlisted.title"), + table_title=localizer.get("admin.registrations.waitlisted.table_title"), + empty_message=localizer.get("admin.registrations.waitlisted.empty"), actions_builder=waitlisted_actions, ) @@ -509,6 +537,7 @@ async def registrations_declined( _: str = Depends(admin_auth), session: Session = Depends(get_session), ): + localizer = current_localizer() event = get_or_create_default_event(session, settings) registrations = ( session.execute( @@ -522,9 +551,9 @@ async def registrations_declined( session, event=event, registrations=registrations, - title="Declined registrations", - table_title="Declined applicants", - empty_message="No declined registrations.", + title=localizer.get("admin.registrations.declined.title"), + table_title=localizer.get("admin.registrations.declined.table_title"), + empty_message=localizer.get("admin.registrations.declined.empty"), actions_builder=declined_actions, ) @@ -538,17 +567,20 @@ async def update_status( _: str = Depends(admin_auth), session: Session = Depends(get_session), ): + localizer = current_localizer() registration = session.get(Registration, registration_id) if not registration: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Registration not found" + status_code=status.HTTP_404_NOT_FOUND, + detail=localizer.get("admin.errors.registration_not_found"), ) try: status_enum = RegistrationStatus(status_value) except ValueError as exc: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown status" + status_code=status.HTTP_400_BAD_REQUEST, + detail=localizer.get("admin.errors.unknown_status"), ) from exc if priority_value is None: @@ -565,7 +597,8 @@ async def update_status( ) except CapacityError: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Attendee limit reached" + status_code=status.HTTP_409_CONFLICT, + detail=localizer.get("admin.errors.attendee_limit_reached"), ) session.flush() @@ -600,15 +633,19 @@ async def delete_registration( _: str = Depends(admin_auth), session: Session = Depends(get_session), ): + localizer = current_localizer() registration = session.get(Registration, registration_id) if not registration: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Registration not found" + status_code=status.HTTP_404_NOT_FOUND, + detail=localizer.get("admin.errors.registration_not_found"), ) session.delete(registration) session.flush() redirect_path = safe_redirect_path(return_to, "/admin/registrations") - return redirect_with_message(redirect_path, "Registration deleted") + return redirect_with_message( + redirect_path, localizer.get("admin.registrations.flash.deleted") + ) @app.post("/admin/registrations/manual") async def add_manual_registration( @@ -622,11 +659,13 @@ async def add_manual_registration( _: str = Depends(admin_auth), session: Session = Depends(get_session), ): + localizer = current_localizer() try: category = RegistrationCategory(category_value) except ValueError as exc: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown category" + status_code=status.HTTP_400_BAD_REQUEST, + detail=localizer.get("admin.errors.unknown_category"), ) from exc event = get_or_create_default_event(session, settings) @@ -646,9 +685,7 @@ async def add_manual_registration( async def urgent(request: Request, _: str = Depends(admin_auth)): return templates.TemplateResponse( "urgent.html", - { - "request": request, - }, + template_context(request), ) @app.post("/admin/urgent") @@ -661,11 +698,7 @@ async def send_urgent( delivered = await broadcast_message(session, request.app.state.bot, message) return templates.TemplateResponse( "urgent.html", - { - "request": request, - "delivered": delivered, - "message": message, - }, + template_context(request, delivered=delivered, message=message), ) @app.post("/admin/event/limit") @@ -676,28 +709,29 @@ async def update_limit( session: Session = Depends(get_session), ): event = get_or_create_default_event(session, settings) + localizer = current_localizer() normalized = limit.strip() if not normalized: event.capacity = None object.__setattr__(settings, "attendee_limit", None) - message = "Attendee limit removed" + message = localizer.get("admin.registrations.flash.limit_removed") else: try: new_limit = int(normalized) except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Limit must be an integer", + detail=localizer.get("admin.errors.limit_integer"), ) from exc if new_limit < 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Limit must be zero or greater", + detail=localizer.get("admin.errors.limit_non_negative"), ) event.capacity = new_limit object.__setattr__(settings, "attendee_limit", new_limit) - message = "Attendee limit updated" + message = localizer.get("admin.registrations.flash.limit_updated") session.flush() return redirect_with_message("/admin/registrations", message) diff --git a/templates/base.html b/templates/base.html index c9f6b99..0967bbc 100644 --- a/templates/base.html +++ b/templates/base.html @@ -2,7 +2,7 @@ - {{ title or 'Admin' }} + {{ title or localizer.get("admin.title") }}