MPV 2 Done, Getting ready for deploy - #74
Conversation
TODO: - create new notification for room & date change - create the admin approval mechanism
- Implemented `clear_aiogram_redis.py` to remove specific keys from Redis related to aiogram and aiogram_dialog. - Added functionality for dry-run mode to preview keys without deletion. fix: Improve meeting info text rendering - Updated `MeetingInfoText` to return "Meeting" if rendered text is empty. fix: Prevent multiple pending updates for meetings - Added check in `on_date_room_btn` to ensure no pending updates exist before allowing changes. refactor: Consolidate meeting update logic - Removed redundant methods for updating meeting date and room. - Introduced `approve_meeting_update` to handle applying updates and notifying participants. feat: Enhance notification system for meeting updates - Added new notification texts and methods for sending update requests and approvals. - Updated notification manager to handle meeting update notifications. fix: Update i18n files to include new notification texts - Added new keys for meeting update notifications in `notifications.ftl`. refactor: Clean up meeting repository methods - Renamed `del_update` to `remove_update` for clarity.
- Changed project name in pyproject.toml from "aiogram-template" to "academic-tutorship-bot". - Added proxy_url field to settings.schema.yaml and updated config_schema.py. - Implemented proxy support in app.py for the bot session. - Updated i18n.py to use the new locales directory path. - Enhanced logging configuration to handle permission errors for file handlers. - Updated dependencies in uv.lock and pyproject.toml, including beanie and aiohttp-socks.
There was a problem hiding this comment.
Pull request overview
Implements MVP2 core functionality for the Academic Tutorship Bot, adding scheduling, notification-bot support, DB repositories/schema, authentication flow, and a new aiogram_dialog-based UI with i18n.
Changes:
- Added scheduling infrastructure (APScheduler + meeting reminder/conduct/finish jobs) and notification bot wiring.
- Introduced new DB schema/repositories/models for students/admins/tutors/meetings/updates/attendance + Alembic migrations.
- Replaced old routers with a multi-dialog UI (root/settings/guide/meetings/tutors/etc.) plus fluent-based localization and new tests/scripts.
Reviewed changes
Copilot reviewed 151 out of 158 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_student_repo.py | Adds StudentRepository create/admin-flag tests. |
| tests/test_scheduling.py | Adds tests for meeting scheduling and reminder job behavior. |
| tests/test_auto_auth_middleware.py | Adds tests for AutoAuthMiddleware status updates. |
| tests/test_admin_repo.py | Adds AdminRepository sync-with-config tests. |
| tests/fixtures.py | Adds async DB/repository fixtures (sqlite in-memory). |
| src/utils/colors.py | Introduces ANSI color constants. |
| src/scheduling/scheduling.py | Implements meeting scheduling + reminder/conduct/finish job handlers. |
| src/scheduling/scheduler.py | Adds scheduler configuration with Redis/Memory jobstore selection. |
| src/prepare.py | Switches pre-commit install command from poetry to uv. |
| src/notifications/texts.py | Adds notification message templates (legacy-style). |
| src/notifications/bot.py | Creates notification bot + dispatcher objects. |
| src/notifications/init.py | Exposes NotificationManager instance and handler init hook. |
| src/domain/models.py | Adds/updates domain models (Student/Tutor/Meeting/etc.) and statuses. |
| src/db/schema.py | Defines SQLAlchemy tables for students/admins/tutors/meetings/updates/etc. |
| src/db/repositories/tutor.py | Adds TutorRepository CRUD + profile/photo/discipline methods. |
| src/db/repositories/sql.py | Adds SQLDatabase + base Repository. |
| src/db/repositories/discipline.py | Adds DisciplineRepository query/create methods. |
| src/db/repositories/admin.py | Adds AdminRepository and sync_with_config implementation. |
| src/db/repositories/init.py | Instantiates shared DB + repository singletons. |
| src/config_schema.py | Extends app settings (accounts integration, db_url, proxy, notification bot token, mock_auth). |
| src/bot/utils.py | Expands bot utilities (dialog helpers, parsing, keyboards, attendance parsing). |
| src/bot/user_errors.py | Adds user-facing exception types for dialog flows. |
| src/bot/routers/user.py | Removes old placeholder dialog router. |
| src/bot/routers/registration.py | Removes old registration flow router. |
| src/bot/routers/queries.py | Adds callback handler router (e.g., delete warning). |
| src/bot/logging_.py | Enhances logging setup and adds structured log helpers. |
| src/bot/i18n.py | Adds fluent-based dialog i18n utilities + notification l10n loader. |
| src/bot/filters.py | Reworks filters for authenticated/status/email-entered flows. |
| src/bot/exceptions.py | Adds auth/authority exception types. |
| src/bot/dialogs/tutors/windows.py | Adds admin tutor-control dialog windows. |
| src/bot/dialogs/tutors/states.py | Adds tutor-control dialog states. |
| src/bot/dialogs/tutors/logic.py | Adds tutor add/remove logic with notifications and safety checks. |
| src/bot/dialogs/tutors/handles.py | Adds tutor-control handlers (select/add/remove). |
| src/bot/dialogs/tutors/getters.py | Adds tutor list/info getters for tutor-control dialog. |
| src/bot/dialogs/tutors/dialog_buttons.py | Adds scrolling/select widgets for tutor-control dialog. |
| src/bot/dialogs/tutors/init.py | Wires tutor-control dialog and auth guard middleware. |
| src/bot/dialogs/tutors_profile/windows.py | Adds tutor profile and profile-control windows. |
| src/bot/dialogs/tutors_profile/states.py | Adds tutor profile dialog states. |
| src/bot/dialogs/tutors_profile/handles.py | Adds tutor profile handlers (select, edit, disciplines). |
| src/bot/dialogs/tutors_profile/getters.py | Adds tutor profile getters (self/other + selected disciplines). |
| src/bot/dialogs/tutors_profile/dialog_buttons.py | Adds tutor profile list scrolling widgets. |
| src/bot/dialogs/tutors_profile/init.py | Wires tutor profile dialog and auth guard middleware. |
| src/bot/dialogs/student_meetings/windows.py | Adds student meeting list/info windows. |
| src/bot/dialogs/student_meetings/states.py | Adds student meeting dialog states. |
| src/bot/dialogs/student_meetings/handles.py | Adds student meeting selection handler. |
| src/bot/dialogs/student_meetings/getters.py | Adds student meeting list/info getters and date filtering. |
| src/bot/dialogs/student_meetings/dialog_buttons.py | Adds meeting list scrolling widgets for students. |
| src/bot/dialogs/student_meetings/init.py | Wires student meeting dialog and auth guard middleware. |
| src/bot/dialogs/root/windows.py | Adds root/start/settings windows and navigation wiring. |
| src/bot/dialogs/root/states.py | Adds root dialog states. |
| src/bot/dialogs/root/handles.py | Adds settings handlers (language toggle, notification toggle, disciplines). |
| src/bot/dialogs/root/getters.py | Adds root start/settings getters. |
| src/bot/dialogs/root/init.py | Wires root dialog and auth guard middleware. |
| src/bot/dialogs/meetings/states.py | Adds tutor/admin meetings dialog states. |
| src/bot/dialogs/meetings/logic.py | Adds meeting lifecycle logic (create/approve/announce/cancel/finish/update-approve). |
| src/bot/dialogs/meetings/getters.py | Adds meetings type/list/info/create getters with role-based filtering. |
| src/bot/dialogs/meetings/dialog_buttons.py | Adds meeting list scrolling widgets for tutor/admin flows. |
| src/bot/dialogs/meetings/init.py | Wires meetings dialog and auth guard middleware. |
| src/bot/dialogs/guide/windows.py | Adds onboarding/guide dialog windows (language/disciplines/notifications). |
| src/bot/dialogs/guide/states.py | Adds guide dialog states. |
| src/bot/dialogs/guide/handles.py | Adds guide handlers (language select, finish guide). |
| src/bot/dialogs/guide/getters.py | Adds guide getters (notification bot activation state, etc.). |
| src/bot/dialogs/guide/init.py | Wires guide dialog and auth guard middleware. |
| src/bot/dialogs/discipline_picker/windows.py | Adds discipline picker windows (language/year/discipline + multi select). |
| src/bot/dialogs/discipline_picker/states.py | Adds discipline picker states. |
| src/bot/dialogs/discipline_picker/handles.py | Adds discipline picker selection handlers. |
| src/bot/dialogs/discipline_picker/getters.py | Adds discipline picker getters and multi-select state building. |
| src/bot/dialogs/discipline_picker/dialog_buttons.py | Adds picker scrolling widgets for language/year/discipline selection. |
| src/bot/dialogs/discipline_picker/init.py | Wires discipline picker dialog and auth guard middleware. |
| src/bot/dialogs/change_meeting/windows.py | Adds meeting edit dialog windows (title/discipline/date/room/tutor/etc.). |
| src/bot/dialogs/change_meeting/states.py | Adds meeting edit dialog states. |
| src/bot/dialogs/change_meeting/logic.py | Adds meeting edit business logic (assign tutor, duration, date combine, etc.). |
| src/bot/dialogs/change_meeting/keyboards.py | Adds “choose user” reply keyboard for tutor assignment. |
| src/bot/dialogs/change_meeting/getters.py | Adds getters for meeting edit UI and tutor lists. |
| src/bot/dialogs/change_meeting/dialog_buttons.py | Adds meeting edit scrolling/select widgets and navigation buttons. |
| src/bot/dialogs/change_meeting/init.py | Wires change-meeting dialog and auth guard middleware. |
| src/bot/dialogs/authentication/windows.py | Adds authentication/bind window. |
| src/bot/dialogs/authentication/states.py | Adds authentication dialog state(s). |
| src/bot/dialogs/authentication/handles.py | Adds “check connected” handler to route into root upon auth. |
| src/bot/dialogs/authentication/getters.py | Adds binding URL getter based on bot username and settings. |
| src/bot/dialogs/authentication/init.py | Wires authentication dialog. |
| src/bot/dialogs/attendance/windows.py | Adds attendance dialog windows (download/resend/add/close). |
| src/bot/dialogs/attendance/states.py | Adds attendance dialog states. |
| src/bot/dialogs/attendance/logic.py | Adds attendance file parsing/storage and close-meeting flow. |
| src/bot/dialogs/attendance/handles.py | Adds attendance handlers (download/resend/close/add email). |
| src/bot/dialogs/attendance/init.py | Wires attendance dialog and auth guard middleware. |
| src/bot/dialog_extension/extended_fsm_context.py | Adds typed FSM state helpers and sync context managers. |
| src/bot/dialog_extension/extended_dialog_manager.py | Adds extended dialog manager utilities (tracking/cleanup/messages/i18n). |
| src/bot/dialog_extension/dialog_wrapper.py | Adds delegating wrapper for DialogManager. |
| src/bot/dialog_extension/init.py | Exports dialog extension helpers. |
| src/bot/custom_widgets/unpacked_list.py | Adds List widget variant that unpacks dict items for fluent formatting. |
| src/bot/custom_widgets/tutors_profile_text.py | Adds custom widget for rendering tutor profile text with fluent formatting. |
| src/bot/custom_widgets/meeting_update_text.py | Adds custom widget for rendering meeting update summaries. |
| src/bot/custom_widgets/meeting_info_text.py | Adds custom widget for rendering meeting info views. |
| src/bot/custom_widgets/meeting_date_room_text.py | Adds custom widget for rendering date/room quick view. |
| src/bot/custom_widgets/init.py | Exports custom widgets. |
| src/bot/constants.py | Adds constants used by middlewares/widgets (i18n format key). |
| src/bot/bot_container.py | Adds a global Bot container to access Bot in lower layers. |
| src/bot/app.py | Reworks app bootstrap: proxy support, auth middleware, dialogs, scheduler, notification polling, startup/shutdown routines. |
| src/accounts_sdk.py | Adds InNoHassle Accounts client + token decoding + user lookup. |
| settings.schema.yaml | Updates settings schema for new config fields (accounts/db/notification bot/etc.). |
| settings.example.yaml | Updates example settings with db_url and notification bot token. |
| scripts/run_tests.sh | Adds uv-based pytest runner script. |
| scripts/clear_aiogram_redis.py | Adds Redis cleanup script for aiogram/aiogram_dialog keys. |
| scripts/alembic_auto_migrations.py | Adds helper for autogenerating alembic migrations from commit message. |
| pyproject.toml | Renames project and adds dependencies + ruff ignore for star imports. |
| logging.yaml | Adds file handler and logger wiring for app logs. |
| locales/ru/LC_MESSAGES/notification_handles.ftl | Adds RU fluent strings for notification-handlers UI. |
| locales/ru/LC_MESSAGES/handles.ftl | Adds RU fluent strings for UI messages. |
| locales/en/LC_MESSAGES/notifications.ftl | Adds EN fluent strings for notification content. |
| locales/en/LC_MESSAGES/notification_handles.ftl | Adds EN fluent strings for notification-handlers UI. |
| locales/en/LC_MESSAGES/handles.ftl | Adds EN fluent strings for UI messages. |
| docs/README.md | Adds naming scheme notes. |
| docs/disciplines.sql | Adds seed SQL for disciplines. |
| docker-compose.yaml | Adds db dependency to app service. |
| alembic/versions/2026_04_05_15_57.py | Migration: meeting.tutor_id FK adjustment. |
| alembic/versions/2026_04_05_15_54.py | Migration: meeting.creator_id nullable change. |
| alembic/versions/2026_04_04_17_51.py | Migration: meeting_update table creation. |
| alembic/versions/2026_03_26_15_26.py | Migration: student.language column addition. |
| alembic/versions/2026_03_21_21_52.py | Migration: student.saw_guide column addition. |
| alembic/versions/2026_03_21_13_46.py | Migration: student.notification_bot_status column addition. |
| alembic/versions/_2026_03_19_16_10_add_meeting_approving_status.py | Migration: inserts APPROVING status via status shift. |
| alembic/versions/_2026_03_09_12_25_email_not_null.py | Migration: email.value NOT NULL. |
| alembic/versions/_2026_03_07_18_29_better_foreign_keys.py | Migration: improves FK ondelete behaviors. |
| alembic/versions/_2026_03_07_15_40_initial.py | Migration: initial schema. |
| alembic/script.py.mako | Adds alembic revision template. |
| alembic/README.md | Adds migration usage docs. |
| alembic/env.py | Adds async alembic env + db_url loading from settings/env. |
| alembic.ini | Adds alembic config and ruff post-write hook. |
| .vscode/launch.json | Adds debug configuration for running bot module. |
| .pre-commit-config.yaml | Adjusts alembic auto-migration hook file scope. |
| .gitignore | Expands ignores (sqlite, logs, OS/editor artifacts). |
| .dockerignore | Ensures locales are included in Docker build context. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| meeting = await meeting_repo.get(meeting_id) | ||
| if not meeting: | ||
| logger.warning("error in _job_meeting_conduct: no meeting") | ||
| try: | ||
| meeting.conduct() | ||
| await meeting_repo.update(meeting, ["status"]) |
There was a problem hiding this comment.
_job_meeting_conduct() logs a warning when the meeting is not found, but then continues into the try-block and will raise an AttributeError on meeting.conduct(). Return immediately after the not-found check to avoid noisy errors and unintended behavior.
| async def _job_meeting_finish(meeting_id: int): | ||
| meeting = await meeting_repo.get(id=meeting_id) | ||
| if not meeting: |
There was a problem hiding this comment.
_job_meeting_finish() calls meeting_repo.get(id=meeting_id), but MeetingRepository.get() takes a positional id argument (no keyword). This will raise a TypeError at runtime; call meeting_repo.get(meeting_id) instead.
| def _parse_redis_url(url: str) -> dict: | ||
| parsed = urlparse(url) | ||
| return { | ||
| "host": parsed.hostname, | ||
| "port": parsed.port, | ||
| "db": int(parsed.path.lstrip("/")) if parsed.path else None, | ||
| "password": parsed.password, | ||
| } |
There was a problem hiding this comment.
_parse_redis_url() returns db=None when the Redis URL has no path. RedisJobStore expects an integer DB index; default to 0 (or omit the key) to avoid runtime errors for URLs like redis://host:6379.
| _executors = {"default": AsyncIOExecutor()} | ||
|
|
||
| _job_defaults = {"misfire_grac"} | ||
|
|
||
| scheduler = AsyncIOScheduler( | ||
| jobstores=_jobstores, | ||
| executors=_executors, | ||
| ) |
There was a problem hiding this comment.
_job_defaults = {"misfire_grac"} is a set (not a dict), has a truncated key name, and is never passed into AsyncIOScheduler. Either remove it or define a proper dict (e.g., misfire_grace_time/coalesce/max_instances) and pass it via job_defaults=.
| photo_stmt = insert(photo).values(telegram_file_id=telegram_file_id, file_path=file_path).returning(photo.c.id) | ||
| async with self._db.engine.begin() as conn: | ||
| result = await conn.execute(photo_stmt) | ||
| photo_id = result.scalar_one() | ||
| tutor_stmt = update(tutor).values(photo_id=photo_id) | ||
| await conn.execute(tutor_stmt) | ||
|
|
||
| async def unset_photo(self, tutor_id: int): | ||
| stmt = update(tutor).values(photo_id=None) | ||
| async with self._db.engine.begin() as conn: | ||
| await conn.execute(stmt) | ||
|
|
There was a problem hiding this comment.
TutorRepository.set_photo() updates the tutor table without a WHERE clause, so it will set photo_id for all tutors. Add where(tutor.c.id == tutor_id) (same issue applies to unset_photo()).
| DISCIPLINE_YEAR_SCROLL = ScrollingGroup( | ||
| Select( | ||
| Format("{item[1]}"), | ||
| id="select_year", | ||
| item_id_getter=(lambda item: item[0]), | ||
| items="years", | ||
| on_click=on_year_select, | ||
| ), | ||
| id="scroll_year", | ||
| width=1, | ||
| height=15, | ||
| hide_pager=True, | ||
| ) | ||
|
|
||
|
|
||
| DISCIPLINE_SCROLL = ScrollingGroup( | ||
| Select( | ||
| Format("{item[1][display]}"), | ||
| id="select_discipline", | ||
| item_id_getter=(lambda item: item[0]), | ||
| items="disciplines", | ||
| on_click=on_discipline_select, | ||
| ), | ||
| id="scroll_year", | ||
| width=1, | ||
| height=30, | ||
| hide_pager=True, | ||
| ) | ||
|
|
||
|
|
||
| DISCIPLINE_MULTI_SCROLL = ScrollingGroup( | ||
| Select( | ||
| Format("{item[2]} {item[1][display]}"), | ||
| id="select_discipline", | ||
| item_id_getter=(lambda item: item[0]), | ||
| items="disciplines_multi", | ||
| on_click=on_discipline_select_multi, | ||
| ), | ||
| id="scroll_year", | ||
| width=1, | ||
| height=30, | ||
| hide_pager=True, | ||
| ) |
There was a problem hiding this comment.
This dialog defines duplicate widget IDs: ScrollingGroup uses id="scroll_year" in three different widgets and Select uses id="select_discipline" twice. aiogram_dialog requires widget IDs to be unique within a dialog; this will raise at startup. Rename these IDs to be distinct (e.g., scroll_discipline, scroll_discipline_multi, select_discipline_multi).
| async def on_toggle_notifications(query: CallbackQuery, _, manager: DialogManager): | ||
| manager = extend_dialog(manager) | ||
| _ = manager.tr | ||
| try: | ||
| self_student = await manager.state.get_self_student() | ||
| s = self_student.settings | ||
| log_info( | ||
| "student.notifications.toggle.requested", | ||
| user_id=query.from_user.id, | ||
| current_value=s.receive_notifications, | ||
| ) | ||
| s.receive_notifications = not s.receive_notifications | ||
| await student_repo.update(self_student, ["receive_notifications"]) | ||
| await notification_manager.send_receive_notification_toggled(self_student.id, s.receive_notifications) | ||
| self_student = await student_repo.get(self_student.telegram_id) # check if notification really sent | ||
|
|
||
| if self_student.notification_bot_status != NotificationBotStatus.ACTIVATED: | ||
| # NOTE: we don't care if user deactivates notifications and blocks bot | ||
| if s.receive_notifications: | ||
| raise PermissionError("Notification bot is not activated") | ||
|
|
||
| await student_repo.update(self_student, ["receive_notifications"]) | ||
| await query.answer(_("Q_SETTINGS_NOTIF_ON") if s.receive_notifications else _("Q_SETTINGS_NOTIF_OFF")) | ||
| await manager.state.set_self_student(self_student) | ||
| log_info( | ||
| "student.notifications.toggle.succeeded", | ||
| user_id=query.from_user.id, | ||
| new_value=s.receive_notifications, | ||
| ) | ||
| except PermissionError: | ||
| log_warning( | ||
| "student.notifications.toggle.permission_denied", | ||
| user_id=query.from_user.id, | ||
| ) | ||
| self_student.settings.receive_notifications = False | ||
| await student_repo.update(self_student, ["receive_notifications"]) | ||
| await query.answer(_("Q_SETTINGS_NOTIF_BOT_NOT_ACTIVATED")) | ||
| except Exception as e: | ||
| log_error("student.notifications.toggle.failed", user_id=query.from_user.id, reason=str(e)) | ||
| raise | ||
| await query.answer() | ||
| await manager.switch_to_current() | ||
|
|
There was a problem hiding this comment.
on_toggle_notifications() answers the callback query inside the try/except and then unconditionally calls await query.answer() again at the end. Answering twice can raise TelegramBadRequest / cause unnecessary API calls; remove the final unconditional answer (or only answer once in a single place).
| def _filter_meetings_by_date(meetings: list[Meeting]) -> list[Meeting]: | ||
| """Filter meetings which finish date is within last 12 hours or later in future""" | ||
| now = datetime.now() | ||
| filtered = [] | ||
| for m in meetings: | ||
| if m.datetime_ and (m.datetime_ + timedelta(seconds=m.duration)) > now: | ||
| filtered.append(m) | ||
| return filtered |
There was a problem hiding this comment.
The docstring says it filters meetings whose finish date is within the last 12 hours, but the implementation only keeps meetings whose (datetime_ + duration) is after now (i.e., not finished yet). Either update the logic to match the 12h requirement or update the docstring to reflect the actual behavior.
| if settings.accounts: | ||
| inh_accounts: InNoHassleAccounts = InNoHassleAccounts( | ||
| api_url=settings.accounts.api_url, | ||
| api_jwt_token=settings.accounts.api_jwt_token.get_secret_value(), | ||
| verify=False, | ||
| ) | ||
| else: | ||
| raise ImportError("InNoHassle Accounts is not configured in ./settings.yaml") |
There was a problem hiding this comment.
accounts_sdk raises ImportError at import time when settings.accounts is not configured. This makes it impossible to run the app in modes like mock_auth (and breaks any module importing inh_accounts). Prefer deferring the error to first use, or set inh_accounts to None/a stub and have middleware/routers handle the missing configuration gracefully.
| format_text = manager.middleware_data.get( | ||
| I18N_FORMAT_KEY, | ||
| _default_format_text, | ||
| ) | ||
|
|
||
| meeting_update = await manager.state.get_value("meeting_updated", default={}) | ||
| meeting = await manager.state.get_meeting() | ||
|
|
||
| update_data = self.__filter_valid_update_data(meeting_update, meeting) | ||
|
|
||
| lines = [] | ||
| lines.append(format_text("MEETING_UPDATE_HEADER")) | ||
| for key, value in update_data.items(): | ||
| if key in ["datetime", "datetime_"]: | ||
| lines.append(format_text("MEETING_UPDATE_DATETIME_LINE", datetime=normalize_l10n_value(value))) | ||
| elif key in ["room"]: | ||
| lines.append(format_text("MEETING_UPDATE_ROOM_LINE", room=value)) | ||
| else: |
There was a problem hiding this comment.
MeetingUpdateText uses the i18n formatter incorrectly: format_text is set to FluentLocalization.format_value, which expects (message_id, args_dict). The code calls it with keyword args (e.g., format_text("MEETING_UPDATE_ROOM_LINE", room=value)) which will raise TypeError. Pass a dict as the second positional argument (or use manager.tr), and consider aligning the state key name (meeting_updated vs meeting_update).
Description of changes
Added core functionality for the MVP 2 of Academic Tutorship Bot
Getting ready for the deploy