diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..4ad13d3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ +## Summary +- + +## Testing +- + +## Checklist +- [ ] Added/updated tests as needed +- [ ] Documentation updated (if applicable) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fcb09e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + quality: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --dev + + - name: Format check + run: uv run ruff format --check . + + - name: Lint + run: uv run ruff check . + + - name: Tests + run: uv run pytest + + - name: Coverage + run: uv run pytest --cov=app --cov-report=term --cov-report=xml --cov-fail-under=70 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..272e177 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Agent Instructions + +## Overview +This repository uses Python 3.11+ with dependencies managed by `uv` and a Docker-based workflow for running the bot in containers. + +## Local setup +- Ensure Python 3.11+ is installed. +- Install `uv` if needed: https://docs.astral.sh/uv/ + +### Install dependencies +```bash +make install +``` + +### Install dev dependencies +```bash +make dev-install +``` + +### Run the bot +```bash +make run +``` + +### Lint/format/test +```bash +make format +make lint +make test +``` + +## Docker workflow +```bash +docker compose up --build +``` + +## Configuration +- Default configuration lives in `config.yaml`. +- You can override values with environment variables like `TELEGRAM_TOKEN`, `ADMIN_USERNAMES`, and `DATABASE_URL`. 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. diff --git a/app/config.py b/app/config.py index 6d86215..f724854 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,4 @@ -import os -import stat -from datetime import datetime +import json from functools import lru_cache from typing import Any, List from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -8,116 +6,100 @@ 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") + locale: str = Field(default="en", env="LOCALE") + + event_name: str = Field(default="Event", env="EVENT_NAME") + attendee_limit: int | None = Field(default=None, env="ATTENDEE_LIMIT") + timezone: str = Field(default="UTC", env="TIMEZONE") + + enable_admin_web: bool = Field(default=False, env="ENABLE_ADMIN_WEB") + admin_web_host: str = Field(default="0.0.0.0", env="ADMIN_WEB_HOST") + admin_web_port: int = Field(default=8000, env="ADMIN_WEB_PORT") + basic_auth_username: str = Field(default="admin", env="ADMIN_BASIC_AUTH_USERNAME") + basic_auth_password: str = Field(default="admin", env="ADMIN_BASIC_AUTH_PASSWORD") + scheduler_interval_seconds: int = Field(default=60, env="SCHEDULER_INTERVAL_SECONDS") 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 customise_sources(cls, init_settings, env_settings, file_secret_settings): + return ( + init_settings, + env_settings, + cls._yaml_settings_source, + file_secret_settings, + ) + + @classmethod + def _yaml_settings_source(cls, settings: BaseSettings) -> dict[str, Any]: + return settings.__class__.load_from_yaml() + + @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) + + @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) + + @validator("timezone") + def validate_timezone(cls, value: str) -> str: # type: ignore[override] + try: + ZoneInfo(value) + except ZoneInfoNotFoundError as exc: + raise ValueError(f"Unknown timezone: {value}") from exc + return value @classmethod - def _parse_admin_ids(cls, value: str | List[int] | None) -> List[int]: - return parse_admin_ids(value) + def _parse_admin_usernames(cls, value: str | List[str] | None) -> List[str]: + return parse_admin_usernames(value) @property - def admin_id_set(self) -> set[int]: - return set(self.admin_ids) + def admin_username_set(self) -> set[str]: + return {name.lower() for name in self.admin_usernames} @property def tzinfo(self) -> ZoneInfo: - try: - return ZoneInfo(self.timezone) - except ZoneInfoNotFoundError: - return ZoneInfo("UTC") + return ZoneInfo(self.timezone) @property def can_persist_timezone(self) -> bool: return config_path() is not None - def set_timezone(self, timezone_name: str) -> bool: + def set_timezone(self, timezone_value: str) -> bool: try: - ZoneInfo(timezone_name) + ZoneInfo(timezone_value) except ZoneInfoNotFoundError as exc: - raise ValueError(f"Unknown timezone '{timezone_name}'") from exc - - object.__setattr__(self, "timezone", timezone_name) - return self._persist_value("timezone", timezone_name) + raise ValueError(f"Unknown timezone: {timezone_value}") from exc - def _persist_value(self, key: str, value: Any) -> bool: + object.__setattr__(self, "timezone", timezone_value) path = config_path() if not path: return False - - data = self.load_from_yaml() - data[key] = 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 + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + data["timezone"] = timezone_value + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") return True @classmethod @@ -131,5 +113,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/locales/en.json b/app/locales/en.json index d829f64..958cfc0 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -44,5 +44,274 @@ "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}] Бот будет присылать только важные уведомления: о включении в участники, за неделю до мероприятия, и во время мероприятий во время начала больших блоков. Рекомендуем оставить их включенными. Отключение: /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_already_started": "event already started", + "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/locales/ru.json b/app/locales/ru.json new file mode 100644 index 0000000..81b9805 --- /dev/null +++ b/app/locales/ru.json @@ -0,0 +1,317 @@ +{ + "buttons": { + "attend": "Принять участие", + "lecturer": "Регистрация как спикер", + "showcase": "Выставка проектов" + }, + "start": { + "returning": "С возвращением, {name}!\n\nМы уже зарегистрировали вас:\n{summary}\n\nИспользуйте /status для проверки статуса или выберите другой вариант ниже.", + "new": "Привет, {name}!\n\nДобро пожаловать в бот {event_name}. Выберите формат участия.", + "summary_item": "• {category}: {status}" + }, + "registration": { + "status": { + "pending": "На рассмотрении", + "approved": "Одобрено", + "approved_suffix": " (одобрено)", + "waitlisted": "Лист ожидания", + "waitlisted_suffix": " (ожидание)", + "rejected": "Отклонено" + }, + "category": { + "attendee": "Участник", + "lecturer": "Спикер", + "showcase": "Выставка проектов" + }, + "callback": { + "unknown_option": "Неизвестный вариант регистрации. Попробуйте снова.", + "waitlisted": "Вы в листе ожидания. Мы сообщим, если появится место!", + "attendee": "Спасибо! Заявка на участие получена. Организатор скоро подтвердит.", + "lecturer": "Спасибо! Заявка на спикера получена. Мы свяжемся с вами.", + "showcase": "Отлично! Заявка на выставку проекта получена.", + "duplicate": "Мы уже получили вашу заявку. Мы будем держать вас в курсе!" + } + }, + "status": { + "none_found": "Мы пока не нашли вашу заявку. Используйте /start для регистрации!", + "line": "{category}: {status}" + }, + "help": { + "text": "Используйте /start, чтобы зарегистрироваться, или /status, чтобы проверить статус." + }, + "admin_notifications": { + "approved": "Отличные новости! Ваша заявка одобрена.", + "approved_priority": "Отличные новости! Ваша заявка одобрена.", + "rejected": "К сожалению, мы не можем подтвердить ваше участие в этот раз.", + "waitlisted": "Вы в листе ожидания. Мы сообщим об изменениях." + }, + "bot": { + "menu": { + "application": "Заявка", + "cancel": "Отмена заявки", + "feedback": "Отзыв", + "schedule": "Афиша", + "status": "Статус", + "notifications": "Нотификации", + "home": "На главную" + }, + "notifications": { + "status": { + "enabled": "Включены", + "disabled": "Выключены" + }, + "message": "[{status}] Бот будет присылать только важные уведомления: о включении в участники, за неделю до мероприятия, и во время мероприятий во время начала больших блоков. Рекомендуем оставить их включенными. Отключение: /notifications_disable, включение: /notifications_enable." + }, + "status": { + "none": "Нет заявки", + "processing": "Заявка в обработке", + "attendee": "Участник", + "waitlist": "Лист ожидания", + "attendee_notification": "Статус обновлен: участник. Будем рады видеть вас на мероприятии. Подробнее можно узнать в Афише." + }, + "templates": { + "missing_welcome": "Нет шаблона сообщения: welcome_message", + "missing_schedule": "Нет шаблона сообщения: 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": "Неизвестная команда", + "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": "Ожидаю сообщение для рассылки", + "awaiting_all": "Ожидаю следующее сообщение для рассылки всем.", + "awaiting_attendees": "Ожидаю следующее сообщение для рассылки участникам." + }, + "database": { + "updated": "База данных обновлена.", + "awaiting_upload": "Ожидаю CSV-файл с пользователями." + }, + "commands_list": "Команды:\n{commands}", + "applications": { + "summary": "заявки: {applications} | участники: {attendees}" + }, + "status": { + "updated": "Статус обновлен." + }, + "event_started": "event_started=true", + "event_already_started": "Мероприятие уже запущено.", + "event_cancelled": "event_started=false", + "event_id_updated": "event_id обновлен." + } + }, + "admin": { + "title": "Админка", + "nav": { + "brand": "Админка мероприятия", + "dashboard": "Дашборд", + "posts": "Запланированные посты", + "registrations": "Регистрации", + "pending": "На рассмотрении", + "approved": "Одобрены", + "priority": "Приоритет", + "waitlisted": "Лист ожидания", + "declined": "Отклонены", + "urgent": "Срочное сообщение" + }, + "dashboard": { + "title": "Обзор мероприятия", + "subtitle": "Сводка по регистрациям, лимиту и рассылкам.", + "timezone_label": "Часовой пояс: {timezone}", + "timezone_warning": "Изменения сохраняются только в памяти.", + "pending_review": "На рассмотрении", + "current_limit": "Текущий лимит", + "no_limit": "Без лимита", + "approved": "Одобрены", + "priority_approvals": "Приоритетные одобрения", + "waitlisted": "Лист ожидания", + "declined": "Отклонены", + "summary_title": "Сводка", + "attendee_limit_label": "Лимит участников:", + "no_limit_set": "Лимит не задан", + "waiting_to_process": "Ожидают рассмотрения:", + "by_category": "Регистрации по категориям", + "upcoming_posts": "Ближайшие посты", + "upcoming_help": "Время показано в {timezone}.", + "no_upcoming_posts": "Нет запланированных постов." + }, + "posts": { + "title": "Запланированные посты для {event_name}", + "flash": { + "scheduled": "Запланирован новый пост", + "cancelled": "Пост отменен" + }, + "timezone": { + "title": "Настройки часового пояса", + "help": "Все время отображается как {timezone}.", + "warning": "Файл конфигурации не найден – выбор сбросится после перезапуска.", + "label": "Часовой пояс", + "save": "Сохранить часовой пояс" + }, + "new": { + "title": "Запланировать объявление", + "help": "Укажите локальное время отправки; мы конвертируем в UTC.", + "fields": { + "title": "Заголовок", + "content": "Содержание", + "send_at": "Отправить в ({timezone})" + }, + "submit": "Запланировать пост" + }, + "upcoming": { + "title": "Ближайшие посты", + "help": "Локальное время в {timezone}.", + "empty": "Постов пока нет." + }, + "sent": { + "title": "Недавно отправленные", + "help": "Время отображается в {timezone}.", + "empty": "Постов пока нет.", + "empty_date": "—" + }, + "table": { + "title": "Заголовок", + "scheduled": "Запланирован", + "send_at": "Отправить в", + "sent_at": "Отправлен" + } + }, + "registrations": { + "pending": { + "title": "Ожидают рассмотрения", + "table_title": "Ожидают рассмотрения", + "empty": "Нет заявок на рассмотрение." + }, + "approved": { + "title": "Одобренные регистрации", + "table_title": "Одобренные участники", + "empty": "Нет одобренных регистраций." + }, + "priority": { + "title": "Приоритетные одобрения", + "table_title": "Одобрены с приоритетом", + "empty": "Нет приоритетных одобрений." + }, + "waitlisted": { + "title": "Лист ожидания", + "table_title": "В листе ожидания", + "empty": "Нет заявок в листе ожидания." + }, + "declined": { + "title": "Отклоненные регистрации", + "table_title": "Отклоненные заявки", + "empty": "Нет отклоненных регистраций." + }, + "capacity": { + "title": "Лимит мероприятия", + "approved": "Одобрено участников:", + "no_limit": "(без лимита)", + "pending": "Ожидают рассмотрения:", + "view": "просмотр", + "limit_label": "Лимит участников", + "limit_placeholder": "Оставьте пустым для снятия лимита", + "limit_help": "Оставьте пустым, чтобы снять лимит.", + "save": "Сохранить лимит" + }, + "manual": { + "title": "Добавить участника вручную", + "display_name": "Имя", + "contact": "Контакт", + "contact_placeholder": "Email, телефон и т.д.", + "category": "Категория", + "notes": "Заметки", + "priority": "Приоритетный участник (игнорировать лимит)", + "submit": "Добавить регистрацию" + }, + "table": { + "name": "Имя", + "category": "Категория", + "status": "Статус", + "priority": "Приоритет", + "notes": "Заметки", + "actions": "Действия" + }, + "flash": { + "deleted": "Регистрация удалена", + "limit_removed": "Лимит снят", + "limit_updated": "Лимит обновлен" + } + }, + "urgent": { + "title": "Срочное уведомление", + "message_label": "Сообщение для рассылки всем", + "send": "Отправить сейчас", + "delivered": "Доставлено {delivered} пользователям." + }, + "actions": { + "approve": "Одобрить", + "approve_priority": "Одобрить с приоритетом", + "waitlist": "В лист ожидания", + "reject": "Отклонить", + "mark_priority": "Пометить как приоритет", + "reset_pending": "Вернуть в ожидание", + "mark_regular": "Снять приоритет", + "delete": "Удалить" + }, + "labels": { + "yes": "Да", + "no": "Нет" + }, + "categories": { + "attendee": "Участник", + "lecturer": "Спикер", + "showcase": "Выставка проектов" + }, + "statuses": { + "pending": "На рассмотрении", + "approved": "Одобрено", + "waitlisted": "Лист ожидания", + "rejected": "Отклонено" + }, + "timezone": { + "updated": "Часовой пояс обновлен на {timezone}{suffix}", + "not_saved_suffix": " (не сохранено на диск)" + }, + "errors": { + "invalid_date_format": "Неверный формат даты", + "timezone_required": "Нужен часовой пояс", + "invalid_timezone": "Неверный часовой пояс", + "post_not_found": "Пост не найден", + "post_already_sent": "Нельзя отменить уже отправленный пост", + "registration_not_found": "Регистрация не найдена", + "unknown_status": "Неизвестный статус", + "attendee_limit_reached": "Достигнут лимит участников", + "unknown_category": "Неизвестная категория", + "limit_integer": "Лимит должен быть целым числом", + "limit_non_negative": "Лимит не может быть отрицательным" + } + } +} diff --git a/app/main.py b/app/main.py index 17b555d..1daf21c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,32 +1,53 @@ import asyncio -import uvicorn +import logging +import os + +from uvicorn import Config, Server 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.utils import config_path from app.web.admin import create_app -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() + admin_server: Server | None = None + admin_task: asyncio.Task | None = None + if settings.enable_admin_web: + admin_app = create_app(settings, bot=application.bot) + admin_server = Server( + Config( + admin_app, + host=settings.admin_web_host, + port=settings.admin_web_port, + loop="asyncio", + log_level="info", + ) + ) + admin_task = asyncio.create_task(admin_server.serve()) + logger.info( + "Admin web enabled on http://%s:%s", settings.admin_web_host, settings.admin_web_port + ) + logger.info("Telegram application initialized; starting polling.") await application.initialize() if application.post_init: await application.post_init(application) @@ -34,9 +55,11 @@ async def run() -> None: await application.start() try: - await server.serve() + await asyncio.Event().wait() finally: - stop_scheduler(scheduler) + if admin_server and admin_task: + admin_server.should_exit = True + await admin_task if application.updater.running: await application.updater.stop() if application.running: @@ -46,8 +69,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..ee8cc51 100644 --- a/app/models.py +++ b/app/models.py @@ -1,44 +1,88 @@ 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=True) 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) + display_name = Column(String(255), nullable=True) contact = Column(String(255), nullable=True) - is_manual = Column(Boolean, default=False, 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) is_subscribed = Column(Boolean, default=True, nullable=False) + is_manual = Column(Boolean, default=False, nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + feedback = relationship("Feedback", back_populates="user", cascade="all, delete-orphan") registrations = relationship( "Registration", back_populates="user", cascade="all, delete-orphan" ) +class Feedback(Base): + __tablename__ = "feedback" + + id = Column(Integer, primary_key=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) + + user = relationship("User", back_populates="feedback") + + +class RegistrationCategory(str, enum.Enum): + ATTENDEE = "attendee" + LECTURER = "lecturer" + SHOWCASE = "showcase" + + +class RegistrationStatus(str, enum.Enum): + PENDING = "pending" + APPROVED = "approved" + WAITLISTED = "waitlisted" + REJECTED = "rejected" + + class Event(Base): __tablename__ = "events" @@ -59,11 +103,10 @@ class Registration(Base): 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) + status = Column(Enum(RegistrationStatus), nullable=False) is_priority = Column(Boolean, default=False, nullable=False) notes = Column(Text, nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) user = relationship("User", back_populates="registrations") event = relationship("Event", back_populates="registrations") @@ -78,3 +121,36 @@ class ScheduledPost(Base): send_at = Column(DateTime, nullable=False) sent_at = Column(DateTime, nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + +class MessageTemplate(Base): + __tablename__ = "message_templates" + + 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) + admin_id = Column(BigInteger, nullable=False, index=True) + waiting_for = Column(Enum(AdminStateType), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + ttl_seconds = Column(Integer, default=300, nullable=False) + + +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 SchemaVersion(Base): + __tablename__ = "schema_version" + + id = Column(Integer, primary_key=True) + version = Column(Integer, nullable=False) diff --git a/app/scheduler.py b/app/scheduler.py index 9122cf6..655223f 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -7,7 +7,6 @@ from app.database import session_scope from app.services.posts import broadcast_post, get_pending_posts - logger = logging.getLogger(__name__) diff --git a/app/services/messaging.py b/app/services/messaging.py index 5b5870d..9dd5e0b 100644 --- a/app/services/messaging.py +++ b/app/services/messaging.py @@ -8,7 +8,9 @@ async def broadcast_message(session: Session, bot: Bot, message: str) -> int: delivered = 0 - users = session.execute(select(User).where(User.is_subscribed.is_(True))).scalars().all() + users = ( + session.execute(select(User).where(User.notifications_enabled.is_(True))).scalars().all() + ) for user in users: if user.telegram_id is None: continue @@ -16,5 +18,5 @@ async def broadcast_message(session: Session, bot: Bot, message: str) -> int: await bot.send_message(chat_id=user.telegram_id, text=message) delivered += 1 except TelegramError: - user.is_subscribed = False + user.notifications_enabled = False return delivered diff --git a/app/services/posts.py b/app/services/posts.py index d2927b0..78493c7 100644 --- a/app/services/posts.py +++ b/app/services/posts.py @@ -1,5 +1,5 @@ -from datetime import datetime, timezone import logging +from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.orm import Session @@ -8,7 +8,6 @@ from app.models import ScheduledPost, User - logger = logging.getLogger(__name__) @@ -50,25 +49,23 @@ def get_pending_posts(session: Session, *, now: datetime | None = None) -> list[ async def broadcast_post(session: Session, bot: Bot, post: ScheduledPost) -> None: - users = session.execute(select(User).where(User.is_subscribed.is_(True))).scalars().all() - logger.info( - "Broadcasting post %s to %s subscribed users", post.id, len(users) + users = ( + session.execute(select(User).where(User.notifications_enabled.is_(True))).scalars().all() ) + logger.info("Broadcasting post %s to %s subscribed users", post.id, len(users)) for user in users: if user.telegram_id is None: continue try: await bot.send_message(chat_id=user.telegram_id, text=f"{post.title}\n\n{post.content}") except TelegramError: - user.is_subscribed = False + user.notifications_enabled = False logger.warning( "Failed to deliver post %s to user %s; unsubscribing.", post.id, user.id, ) else: - logger.debug( - "Delivered post %s to user %s", post.id, user.id - ) + logger.debug("Delivered post %s to user %s", post.id, user.id) post.sent_at = datetime.now(timezone.utc) logger.info("Post %s marked as sent at %s", post.id, post.sent_at.isoformat()) diff --git a/app/services/registrations.py b/app/services/registrations.py index 5bdc467..0cff551 100644 --- a/app/services/registrations.py +++ b/app/services/registrations.py @@ -1,4 +1,5 @@ from dataclasses import dataclass + from sqlalchemy import Select, func, select from sqlalchemy.orm import Session diff --git a/app/services/users.py b/app/services/users.py index ea4cea1..634a968 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -1,7 +1,6 @@ -from telegram import User as TGUser - from sqlalchemy import select from sqlalchemy.orm import Session +from telegram import User as TGUser from app.models import User diff --git a/app/telebot/bot.py b/app/telebot/bot.py index 1d2c374..11dd104 100644 --- a/app/telebot/bot.py +++ b/app/telebot/bot.py @@ -1,10 +1,18 @@ -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 +logger = logging.getLogger(__name__) + -def build_application(settings: Settings) -> Application: +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..23a07a0 100644 --- a/app/telebot/handlers.py +++ b/app/telebot/handlers.py @@ -1,62 +1,210 @@ -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update -from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes +import asyncio +import csv +import io +import logging +from datetime import datetime, timedelta + from sqlalchemy import select +from telegram import ReplyKeyboardMarkup, Update +from telegram.ext import ( + Application, + CommandHandler, + ContextTypes, + ConversationHandler, + MessageHandler, + filters, +) -from app.config import Settings, get_settings +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.localization import DEFAULT_LOCALE, get_localizer +from app.models import ( + AdminState, + AdminStateType, + EventState, + Feedback, + MessageTemplate, + User, + UserStatus, +) +logger = logging.getLogger(__name__) -REGISTRATION_CALLBACK_PREFIX = "register:" +def get_bot_localizer(): + settings = get_settings() + locale = getattr(settings, "locale", DEFAULT_LOCALE) + return get_localizer(locale) -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}" - ), - ) - ], + +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 +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: + 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: "bot.status.none", + UserStatus.PROCESSING: "bot.status.processing", + UserStatus.ATTENDEE: "bot.status.attendee", + UserStatus.WAITLIST: "bot.status.waitlist", + } + return localizer.get(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: + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.templates.missing_welcome")) + return + try: + 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, + ) + except Exception: + logger.exception("Failed to send welcome template message") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.templates.missing_welcome")) + + +async def send_schedule_message(update: Update, template: MessageTemplate | None) -> None: + if not update.effective_chat: + return + if not template: + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.templates.missing_schedule")) + return + try: + 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, + ) + except Exception: + logger.exception("Failed to send schedule template message") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.templates.missing_schedule")) async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -65,149 +213,845 @@ 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() - ) - - localizer = get_localizer(settings.locale) + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + template = get_template(session, "welcome_message") - 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, - ) + localizer = get_bot_localizer() + 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=localizer.get("bot.messages.main_menu"), + 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) + 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 + + +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() + localizer = get_bot_localizer() + await update.message.reply_text(localizer.get("bot.application.ask_job")) + 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() + localizer = get_bot_localizer() + await update.message.reply_text(localizer.get("bot.application.ask_career")) + 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) + 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() + 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() + 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 - 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 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) + 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), + ) -async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: +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") + localizer = get_bot_localizer() + await send_schedule_message(update, template) + await context.bot.send_message( + chat_id=chat.id, + text=localizer.get("bot.messages.main_menu"), + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) - settings = get_settings() - localizer = get_localizer(settings.locale) +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) + 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 + + +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) + 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 + + +async def feedback_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + return await application_cancel(update, context) + + +async def send_attendee_notification(bot, telegram_id: int) -> None: + await asyncio.sleep(30) + with session_scope() as session: + user = session.scalar(select(User).where(User.telegram_id == telegram_id)) + if not user or user.status != UserStatus.ATTENDEE or not user.notifications_enabled: + return + localizer = get_bot_localizer() + await bot.send_message( + chat_id=telegram_id, + text=localizer.get("bot.status.attendee_notification"), + ) + + +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: - db_user = get_or_create_user(session, user) - event = get_or_create_default_event(session, settings) - registrations = ( + 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(localizer.get("bot.admin.errors.expected_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(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(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( + localizer.get("bot.admin.broadcast.awaiting_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() ) + 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) - if not registrations: - await context.bot.send_message( - chat_id=chat.id, - text=localizer.get("status.none_found"), + +async def broadcast_text(bot, text: str) -> None: + with session_scope() as session: + users = ( + session.execute(select(User).where(User.notifications_enabled.is_(True))) + .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: + 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 + username = row.get("username") or "" + if is_admin(username): + 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: + normalized_status = status_value.strip().upper() + if normalized_status in UserStatus.__members__: + user.status = UserStatus[normalized_status] + else: + for candidate in UserStatus: + if candidate.value.upper() == normalized_status: + user.status = candidate + break + if row.get("notifications_enabled") is not None: + user.notifications_enabled = row["notifications_enabled"].lower() == "true" + user.updated_at = datetime.utcnow() + localizer = get_bot_localizer() + await update.message.reply_text(localizer.get("bot.admin.database.updated")) - 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)) +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( + get_bot_localizer().get("bot.admin.errors.unknown_or_forbidden") + ) + ) + return False + return True -async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - chat = update.effective_chat - if not chat: +async def admin_help(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"), + 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}", + ] + 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: + 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) + 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: + 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.SCHEDULE) + 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: + 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) + 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: + 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) + 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: + 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) + 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: + 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]) + 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}") + 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: + 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: + 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)) + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.status.updated")) + + +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: + 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: + 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: + 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)) + 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: + if not ensure_admin(update): + return + with session_scope() as session: + state = get_or_create_event_state(session) + if state.event_started: + localizer = get_bot_localizer() + if update.effective_chat: + await update.effective_chat.send_message( + localizer.get("bot.admin.event_already_started") + ) + return + state.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: + if not ensure_admin(update): + return + with session_scope() as session: + state = get_or_create_event_state(session) + state.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: + 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: + 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] + 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: + 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( + get_bot_localizer().get("bot.admin.errors.unknown_or_forbidden") + ) + else: + await update.effective_chat.send_message( + get_bot_localizer().get("bot.admin.errors.unknown") + ) + + 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/app/web/admin.py b/app/web/admin.py index a2a61f9..9f93454 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -1,6 +1,7 @@ import asyncio import secrets from datetime import datetime, timezone +from typing import Annotated from urllib.parse import quote from fastapi import Depends, FastAPI, Form, HTTPException, Request, status @@ -29,7 +30,6 @@ update_registration_status, ) - templates = Jinja2Templates(directory="templates") security = HTTPBasic() @@ -42,9 +42,12 @@ 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), + credentials: Annotated[HTTPBasicCredentials, Depends(security)], ) -> str: correct_username = secrets.compare_digest( credentials.username, settings.basic_auth_username @@ -78,22 +81,21 @@ def format_local(dt: datetime | None) -> str | None: return dt.astimezone(settings.tzinfo).strftime("%Y-%m-%d %H:%M") @app.get("/") - async def index(_: str = Depends(admin_auth)): + async def index(_: Annotated[str, Depends(admin_auth)]): return RedirectResponse(url="/admin", status_code=status.HTTP_302_FOUND) @app.get("/admin") async def dashboard( - request: Request, _: str = Depends(admin_auth), session: Session = Depends(get_session) + request: Request, + _: Annotated[str, Depends(admin_auth)], + session: Annotated[Session, Depends(get_session)], ): event = get_or_create_default_event(session, settings) - status_rows = ( - session.execute( - select(Registration.status, func.count(Registration.id)) - .where(Registration.event_id == event.id) - .group_by(Registration.status) - ) - .all() - ) + status_rows = session.execute( + select(Registration.status, func.count(Registration.id)) + .where(Registration.event_id == event.id) + .group_by(Registration.status) + ).all() status_counts = {status: count for status, count in status_rows} priority_count = ( session.scalar( @@ -104,14 +106,11 @@ async def dashboard( ) or 0 ) - category_rows = ( - session.execute( - select(Registration.category, func.count(Registration.id)) - .where(Registration.event_id == event.id) - .group_by(Registration.category) - ) - .all() - ) + category_rows = session.execute( + select(Registration.category, func.count(Registration.id)) + .where(Registration.event_id == event.id) + .group_by(Registration.category) + ).all() category_counts = {category.value: count for category, count in category_rows} upcoming = ( session.execute( @@ -145,21 +144,23 @@ 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") async def posts( - request: Request, _: str = Depends(admin_auth), session: Session = Depends(get_session) + request: Request, + _: Annotated[str, Depends(admin_auth)], + session: Annotated[Session, Depends(get_session)], ): upcoming = ( session.execute( @@ -200,31 +201,33 @@ 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") async def create_post( request: Request, + _: Annotated[str, Depends(admin_auth)], + session: Annotated[Session, Depends(get_session)], title: str = Form(...), content: str = Form(...), send_at: str = Form(...), - _: 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,19 +238,20 @@ 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( + _: Annotated[str, Depends(admin_auth)], timezone_value: str = Form(...), 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,46 +259,52 @@ 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") async def cancel_post( request: Request, post_id: int, - _: str = Depends(admin_auth), - session: Session = Depends(get_session), + _: Annotated[str, Depends(admin_auth)], + session: Annotated[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 +351,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): @@ -410,9 +428,10 @@ def base_registration_query(event, status_filter): @app.get("/admin/registrations") async def registrations_pending( request: Request, - _: str = Depends(admin_auth), - session: Session = Depends(get_session), + _: Annotated[str, Depends(admin_auth)], + session: Annotated[Session, Depends(get_session)], ): + localizer = current_localizer() event = get_or_create_default_event(session, settings) registrations = ( session.execute( @@ -426,9 +445,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, @@ -437,9 +456,10 @@ async def registrations_pending( @app.get("/admin/registrations/approved") async def registrations_approved( request: Request, - _: str = Depends(admin_auth), - session: Session = Depends(get_session), + _: Annotated[str, Depends(admin_auth)], + session: Annotated[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,18 +470,19 @@ 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, ) @app.get("/admin/registrations/approved-priority") async def registrations_approved_priority( request: Request, - _: str = Depends(admin_auth), - session: Session = Depends(get_session), + _: Annotated[str, Depends(admin_auth)], + session: Annotated[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,18 +493,19 @@ 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, ) @app.get("/admin/registrations/waitlisted") async def registrations_waitlisted( request: Request, - _: str = Depends(admin_auth), - session: Session = Depends(get_session), + _: Annotated[str, Depends(admin_auth)], + session: Annotated[Session, Depends(get_session)], ): + localizer = current_localizer() event = get_or_create_default_event(session, settings) registrations = ( session.execute( @@ -497,18 +519,19 @@ 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, ) @app.get("/admin/registrations/declined") async def registrations_declined( request: Request, - _: str = Depends(admin_auth), - session: Session = Depends(get_session), + _: Annotated[str, Depends(admin_auth)], + session: Annotated[Session, Depends(get_session)], ): + localizer = current_localizer() event = get_or_create_default_event(session, settings) registrations = ( session.execute( @@ -522,9 +545,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, ) @@ -532,23 +555,26 @@ async def registrations_declined( async def update_status( request: Request, registration_id: int, + _: Annotated[str, Depends(admin_auth)], + session: Annotated[Session, Depends(get_session)], status_value: str = Form(...), priority_value: str | None = Form(None), return_to: str | None = Form(None), - _: 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: @@ -563,14 +589,15 @@ async def update_status( status=status_enum, is_priority=priority_flag, ) - except CapacityError: + except CapacityError as exc: 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"), + ) from exc session.flush() - if registration.user.telegram_id: + if registration.user.telegram_id and registration.user.notifications_enabled: message = None localizer = current_localizer() if status_enum == RegistrationStatus.APPROVED: @@ -596,37 +623,43 @@ async def update_status( async def delete_registration( request: Request, registration_id: int, + _: Annotated[str, Depends(admin_auth)], + session: Annotated[Session, Depends(get_session)], return_to: str | None = Form(None), - _: 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( request: Request, + _: Annotated[str, Depends(admin_auth)], + session: Annotated[Session, Depends(get_session)], display_name: str = Form(...), contact: str | None = Form(None), category_value: str = Form(...), notes: str | None = Form(None), priority: bool = Form(False), return_to: str | None = Form(None), - _: 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) @@ -643,61 +676,56 @@ async def add_manual_registration( return RedirectResponse(redirect_path, status_code=status.HTTP_303_SEE_OTHER) @app.get("/admin/urgent") - async def urgent(request: Request, _: str = Depends(admin_auth)): + async def urgent(request: Request, _: Annotated[str, Depends(admin_auth)]): return templates.TemplateResponse( "urgent.html", - { - "request": request, - }, + template_context(request), ) @app.post("/admin/urgent") async def send_urgent( request: Request, + _: Annotated[str, Depends(admin_auth)], + session: Annotated[Session, Depends(get_session)], message: str = Form(...), - _: str = Depends(admin_auth), - session: Session = Depends(get_session), ): 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") async def update_limit( request: Request, + _: Annotated[str, Depends(admin_auth)], + session: Annotated[Session, Depends(get_session)], limit: str = Form(...), - _: str = Depends(admin_auth), - 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/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 diff --git a/pyproject.toml b/pyproject.toml index 239fa09..654d9fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,13 +9,13 @@ authors = [ ] dependencies = [ "apscheduler>=3.10", - "fastapi>=0.110", + "fastapi>=0.125", "jinja2>=3.1", "pydantic>=1.10,<2", - "python-multipart>=0.0.6", + "python-multipart>=0.0.20", "python-telegram-bot>=20.7", "sqlalchemy>=2.0", - "uvicorn[standard]>=0.23", + "uvicorn>=0.29", "pyyaml>=6.0", ] @@ -25,6 +25,9 @@ dev = [ "ruff>=0.4.2", "pytest>=8.2", "pytest-cov>=4.1", + "python-multipart>=0.0.20", + "pyyaml>=6.0", + "sqlalchemy>=2.0", ] [tool.pytest.ini_options] @@ -44,4 +47,3 @@ select = ["E", "F", "I", "B"] [tool.ruff.format] quote-style = "double" indent-style = "space" - 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 @@
-