From 3c847594502ee9d0cd0c636137e78f1ef4c440fa Mon Sep 17 00:00:00 2001 From: Sonny V Date: Wed, 19 Nov 2025 01:43:57 +0100 Subject: [PATCH 1/7] build: do not hide gitignored files --- .vscode/settings.json | 1 - 1 file changed, 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 212fc29..7ce7dc4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,4 @@ "editor.rulers": [ 120 ], - "explorer.excludeGitIgnore": true } \ No newline at end of file From 4a5a7918da6f36e3736320bb1095d53884f3a794 Mon Sep 17 00:00:00 2001 From: Sonny V Date: Wed, 19 Nov 2025 01:47:43 +0100 Subject: [PATCH 2/7] feat: add telegram app This app contains base classes to be used in this project. They subclass the base classes of django_telegram_app, but with project-specific fields and behaviour. The settings have also been update to include telegram in INSTALLED_APPS, use a swapped telegram_settings model and hide the ModelAdmin for telegram_settings model as we use the inlined one on the user --- src/apps/telegram/__init__.py | 0 src/apps/telegram/apps.py | 10 ++++++ src/apps/telegram/management/__init__.py | 1 + src/apps/telegram/management/base.py | 32 ++++++++++++++++++++ src/apps/telegram/migrations/0001_initial.py | 31 +++++++++++++++++++ src/apps/telegram/migrations/__init__.py | 0 src/apps/telegram/models.py | 12 ++++++++ src/apps/telegram/telegrambot/__init__.py | 1 + src/apps/telegram/telegrambot/base.py | 19 ++++++++++++ src/apps/telegram/tests.py | 3 ++ src/ida/settings.py | 4 +++ 11 files changed, 113 insertions(+) create mode 100644 src/apps/telegram/__init__.py create mode 100644 src/apps/telegram/apps.py create mode 100644 src/apps/telegram/management/__init__.py create mode 100644 src/apps/telegram/management/base.py create mode 100644 src/apps/telegram/migrations/0001_initial.py create mode 100644 src/apps/telegram/migrations/__init__.py create mode 100644 src/apps/telegram/models.py create mode 100644 src/apps/telegram/telegrambot/__init__.py create mode 100644 src/apps/telegram/telegrambot/base.py create mode 100644 src/apps/telegram/tests.py diff --git a/src/apps/telegram/__init__.py b/src/apps/telegram/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/telegram/apps.py b/src/apps/telegram/apps.py new file mode 100644 index 0000000..e5e7efb --- /dev/null +++ b/src/apps/telegram/apps.py @@ -0,0 +1,10 @@ +"""Configuration for the telegram app.""" + +from django.apps import AppConfig + + +class TelegramConfig(AppConfig): + """Configuration for the telegram app.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "apps.telegram" diff --git a/src/apps/telegram/management/__init__.py b/src/apps/telegram/management/__init__.py new file mode 100644 index 0000000..5256935 --- /dev/null +++ b/src/apps/telegram/management/__init__.py @@ -0,0 +1 @@ +"""Management package for the telegram app.""" diff --git a/src/apps/telegram/management/base.py b/src/apps/telegram/management/base.py new file mode 100644 index 0000000..c02238b --- /dev/null +++ b/src/apps/telegram/management/base.py @@ -0,0 +1,32 @@ +"""Base command for telegram management commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.utils.translation import override +from django_telegram_app.bot.bot import handle_update +from django_telegram_app.management.base import BaseTelegramCommand + +from apps.telegram.models import TelegramSettings +from apps.users.models import IdaUser + +if TYPE_CHECKING: + from django_telegram_app.models import AbstractTelegramSettings + + +class IdaBaseTelegramCommand(BaseTelegramCommand): + """Base command for telegram management commands.""" + + def get_telegram_settings_filter(self): + """Filter to get only active users.""" + return {"user__is_active": True} + + def handle_command(self, telegram_settings: AbstractTelegramSettings, command_text: str): + """Handle the update in the current user's language.""" + assert isinstance(telegram_settings, TelegramSettings) + assert isinstance(telegram_settings.user, IdaUser) + + update = {"message": {"chat": {"id": telegram_settings.chat_id}, "text": command_text}} + with override(telegram_settings.user.language): + handle_update(update, telegram_settings) diff --git a/src/apps/telegram/migrations/0001_initial.py b/src/apps/telegram/migrations/0001_initial.py new file mode 100644 index 0000000..b0909dc --- /dev/null +++ b/src/apps/telegram/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.8 on 2025-11-18 23:38 + +import django.core.serializers.json +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TelegramSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('chat_id', models.IntegerField(unique=True, verbose_name='chat id')), + ('data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder, verbose_name='data')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/apps/telegram/migrations/__init__.py b/src/apps/telegram/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/telegram/models.py b/src/apps/telegram/models.py new file mode 100644 index 0000000..74a86ac --- /dev/null +++ b/src/apps/telegram/models.py @@ -0,0 +1,12 @@ +"""Models for the telegram app.""" + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_telegram_app.models import AbstractTelegramSettings + + +class TelegramSettings(AbstractTelegramSettings): + """Custom Telegram settings model.""" + + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_("user")) diff --git a/src/apps/telegram/telegrambot/__init__.py b/src/apps/telegram/telegrambot/__init__.py new file mode 100644 index 0000000..4a28eb4 --- /dev/null +++ b/src/apps/telegram/telegrambot/__init__.py @@ -0,0 +1 @@ +"""Telegrambot package for the telegram app.""" diff --git a/src/apps/telegram/telegrambot/base.py b/src/apps/telegram/telegrambot/base.py new file mode 100644 index 0000000..ce1cbc6 --- /dev/null +++ b/src/apps/telegram/telegrambot/base.py @@ -0,0 +1,19 @@ +"""Base telegram settings.""" + +from abc import ABC + +from django_telegram_app.bot.base import BaseCommand, Step + +from apps.telegram.models import TelegramSettings + + +class TelegramCommand(BaseCommand, ABC): + """Project specific base class for telegram commands.""" + + settings: TelegramSettings + + +class TelegramStep(Step, ABC): + """Project specific base class for telegram command steps.""" + + command: TelegramCommand diff --git a/src/apps/telegram/tests.py b/src/apps/telegram/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/src/apps/telegram/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/src/ida/settings.py b/src/ida/settings.py index a4e4007..f73f9b8 100644 --- a/src/ida/settings.py +++ b/src/ida/settings.py @@ -47,6 +47,7 @@ "apps.users", "apps.timesheets", "apps.projects", + "apps.telegram", "django_telegram_app", ] @@ -208,6 +209,9 @@ "WEBHOOK_TOKEN": env.read("TELEGRAM_WEBHOOK_TOKEN"), "ROOT_URL": env.read("TELEGRAM_ROOT_URL", "telegram/"), "WEBHOOK_URL": env.read("TELEGRAM_WEBHOOK_URL", "webhook"), + "REGISTER_DEFAULT_ADMIN": False, } +TELEGRAM_SETTINGS_MODEL = "telegram.TelegramSettings" + DOMAIN_NAME = env.read("DJANGO_DOMAIN_NAME", "http://localhost:8000") From a00956bb8ba8e275affff1509a7d79b628930f7f Mon Sep 17 00:00:00 2001 From: Sonny V Date: Wed, 19 Nov 2025 01:56:16 +0100 Subject: [PATCH 3/7] feat!: update references to BaseCommand and Step These references have been replaced to point to the custom TelegramCommand and TelegramStep for better type inference. Management commands now point to IdaBaseManagementCommand to ensure it's localized and only ran for active users --- .../commands/startcompletetimesheet.py | 4 ++-- .../management/commands/startregisterwork.py | 4 ++-- .../telegrambot/commands/completetimesheet.py | 7 +++--- .../telegrambot/commands/editwork.py | 7 +++--- .../telegrambot/commands/registerovertime.py | 7 +++--- .../telegrambot/commands/registerwork.py | 7 +++--- .../telegrambot/commands/requestoverview.py | 7 +++--- src/apps/timesheets/telegrambot/steps/act.py | 18 +++++++------- .../timesheets/telegrambot/steps/confirm.py | 11 +++++---- .../timesheets/telegrambot/steps/select.py | 24 ++++++++++--------- src/apps/timesheets/telegrambot/steps/show.py | 4 ++-- src/apps/timesheets/telegrambot/steps/wait.py | 13 ++++++---- 12 files changed, 59 insertions(+), 54 deletions(-) diff --git a/src/apps/timesheets/management/commands/startcompletetimesheet.py b/src/apps/timesheets/management/commands/startcompletetimesheet.py index 85435ec..184088e 100644 --- a/src/apps/timesheets/management/commands/startcompletetimesheet.py +++ b/src/apps/timesheets/management/commands/startcompletetimesheet.py @@ -1,12 +1,12 @@ """Django command to start the CompleteTimesheet command for active users with a chat_id.""" from django.utils import timezone -from django_telegram_app.management.base import BaseTelegramCommand +from apps.telegram.management.base import IdaBaseTelegramCommand from apps.timesheets.telegrambot.commands.completetimesheet import Command as CompleteTimesheetCommand -class Command(BaseTelegramCommand): +class Command(IdaBaseTelegramCommand): """Start the CompleteTimesheet command.""" help = "Start the CompleteTimesheet command to let users complete their timesheets." diff --git a/src/apps/timesheets/management/commands/startregisterwork.py b/src/apps/timesheets/management/commands/startregisterwork.py index 389453c..90fe72f 100644 --- a/src/apps/timesheets/management/commands/startregisterwork.py +++ b/src/apps/timesheets/management/commands/startregisterwork.py @@ -1,12 +1,12 @@ """Django command to start a RegisterWork command for active users with a chat_id.""" from django.utils import timezone -from django_telegram_app.management.base import BaseTelegramCommand +from apps.telegram.management.base import IdaBaseTelegramCommand from apps.timesheets.telegrambot.commands.registerwork import Command as RegisterWorkCommand -class Command(BaseTelegramCommand): +class Command(IdaBaseTelegramCommand): """Start a RegisterWork command.""" help = "Start a RegisterWork command to let users register their work hours." diff --git a/src/apps/timesheets/telegrambot/commands/completetimesheet.py b/src/apps/timesheets/telegrambot/commands/completetimesheet.py index 1257126..6f36888 100644 --- a/src/apps/timesheets/telegrambot/commands/completetimesheet.py +++ b/src/apps/timesheets/telegrambot/commands/completetimesheet.py @@ -1,16 +1,15 @@ """Complete timesheet command for the Telegram bot.""" -from django_telegram_app.bot.base import BaseCommand, Step - +from apps.telegram.telegrambot.base import TelegramCommand from apps.timesheets.telegrambot.steps import Confirm, MarkTimesheetAsCompleted, SelectTimesheet -class Command(BaseCommand): +class Command(TelegramCommand): """Represent the complete timesheet command.""" description = "Mark a timesheet as completed" @property - def steps(self) -> list[Step]: + def steps(self): """Return the steps of the command.""" return [SelectTimesheet(self), Confirm(self, steps_back=1), MarkTimesheetAsCompleted(self)] diff --git a/src/apps/timesheets/telegrambot/commands/editwork.py b/src/apps/timesheets/telegrambot/commands/editwork.py index a36db9f..91de93e 100644 --- a/src/apps/timesheets/telegrambot/commands/editwork.py +++ b/src/apps/timesheets/telegrambot/commands/editwork.py @@ -1,16 +1,15 @@ """Edit work command for the Telegram bot.""" -from django_telegram_app.bot.base import BaseCommand, Step - +from apps.telegram.telegrambot.base import TelegramCommand from apps.timesheets.telegrambot.steps import EditWorkedHours, SelectExistingDay, SelectWorkedHours -class Command(BaseCommand): +class Command(TelegramCommand): """Represent the edit work command.""" description = "Edit previously registered working hours" @property - def steps(self) -> list[Step]: + def steps(self): """Return the steps of the command.""" return [SelectExistingDay(self), SelectWorkedHours(self, steps_back=1), EditWorkedHours(self)] diff --git a/src/apps/timesheets/telegrambot/commands/registerovertime.py b/src/apps/timesheets/telegrambot/commands/registerovertime.py index 3019454..1230c0f 100644 --- a/src/apps/timesheets/telegrambot/commands/registerovertime.py +++ b/src/apps/timesheets/telegrambot/commands/registerovertime.py @@ -1,7 +1,6 @@ """Register overtime command for the Telegram bot.""" -from django_telegram_app.bot.base import BaseCommand, Step - +from apps.telegram.telegrambot.base import TelegramCommand from apps.timesheets.telegrambot.steps import ( CombineDateTime, Confirm, @@ -14,13 +13,13 @@ ) -class Command(BaseCommand): +class Command(TelegramCommand): """Represent the register overtime command.""" description = "Register overtime for a specific day on a specific project." @property - def steps(self) -> list[Step]: + def steps(self): """Return the steps of the command.""" return [ SelectProject(self), diff --git a/src/apps/timesheets/telegrambot/commands/registerwork.py b/src/apps/timesheets/telegrambot/commands/registerwork.py index 74911a2..8353ff2 100644 --- a/src/apps/timesheets/telegrambot/commands/registerwork.py +++ b/src/apps/timesheets/telegrambot/commands/registerwork.py @@ -1,16 +1,15 @@ """Register work command for the Telegram bot.""" -from django_telegram_app.bot.base import BaseCommand, Step - +from apps.telegram.telegrambot.base import TelegramCommand from apps.timesheets.telegrambot.steps import RegisterWorkedHours, SelectMissingDay, SelectWorkedHours -class Command(BaseCommand): +class Command(TelegramCommand): """Represent the register work command.""" description = "Register working hours for a specific day on a specific project." @property - def steps(self) -> list[Step]: + def steps(self): """Return the steps of the command.""" return [SelectMissingDay(self), SelectWorkedHours(self, steps_back=1), RegisterWorkedHours(self)] diff --git a/src/apps/timesheets/telegrambot/commands/requestoverview.py b/src/apps/timesheets/telegrambot/commands/requestoverview.py index 191e397..a1baef7 100644 --- a/src/apps/timesheets/telegrambot/commands/requestoverview.py +++ b/src/apps/timesheets/telegrambot/commands/requestoverview.py @@ -1,17 +1,16 @@ """Request Overview command for the Telegram bot.""" -from django_telegram_app.bot.base import BaseCommand, Step - +from apps.telegram.telegrambot.base import TelegramCommand from apps.timesheets.telegrambot.steps import SelectOverviewType, SelectTimesheet, ShowOverview -class Command(BaseCommand): +class Command(TelegramCommand): """Represent the request overview command.""" description = "Request an overview of a timesheet and its items." @property - def steps(self) -> list[Step]: + def steps(self): """Return the steps of the command.""" return [ SelectTimesheet(self, filter_kwargs={"user": self.settings.user}), diff --git a/src/apps/timesheets/telegrambot/steps/act.py b/src/apps/timesheets/telegrambot/steps/act.py index ce23fe4..fe6a159 100644 --- a/src/apps/timesheets/telegrambot/steps/act.py +++ b/src/apps/timesheets/telegrambot/steps/act.py @@ -8,19 +8,21 @@ from django.core.exceptions import ValidationError from django.db import transaction -from django_telegram_app.bot.base import Step from django_telegram_app.bot.bot import send_message +from apps.telegram.telegrambot.base import TelegramStep from apps.timesheets.models import TimeRangeItemTypeRule, Timesheet, TimesheetItem, WeekdayItemTypeRule if TYPE_CHECKING: - from django_telegram_app.bot.base import BaseCommand, TelegramUpdate + from django_telegram_app.bot.base import TelegramUpdate + from apps.telegram.telegrambot.base import TelegramCommand -class CombineDateTime(Step): + +class CombineDateTime(TelegramStep): """Represent the combine date and time step in a Telegram bot command.""" - def __init__(self, command: BaseCommand, date_key: str, time_key: str, unique_id: str | None = None): + def __init__(self, command: TelegramCommand, date_key: str, time_key: str, unique_id: str | None = None): """Initialize the combine date and time step.""" self.date_key = date_key self.time_key = time_key @@ -59,7 +61,7 @@ def _validate_time_format(self, time_str: str): raise exc -class EditWorkedHours(Step): +class EditWorkedHours(TelegramStep): """Represent the editing of work step in a Telegram bot command.""" def handle(self, telegram_update: "TelegramUpdate"): @@ -86,7 +88,7 @@ def _editwork(self, data: dict): timesheet_item.save() -class InsertTimesheetItems(Step): +class InsertTimesheetItems(TelegramStep): """Represent the step to insert timesheet items.""" def handle(self, telegram_update: "TelegramUpdate"): @@ -257,7 +259,7 @@ def _add_item( ) -class MarkTimesheetAsCompleted(Step): +class MarkTimesheetAsCompleted(TelegramStep): """Represent the step to mark the selected timesheet as completed.""" def handle(self, telegram_update: "TelegramUpdate"): @@ -274,7 +276,7 @@ def handle(self, telegram_update: "TelegramUpdate"): self.command.next_step(self.name, telegram_update) -class RegisterWorkedHours(Step): +class RegisterWorkedHours(TelegramStep): """Represent the registration of work step in a Telegram bot command.""" def handle(self, telegram_update): diff --git a/src/apps/timesheets/telegrambot/steps/confirm.py b/src/apps/timesheets/telegrambot/steps/confirm.py index 12236f8..a121a08 100644 --- a/src/apps/timesheets/telegrambot/steps/confirm.py +++ b/src/apps/timesheets/telegrambot/steps/confirm.py @@ -5,11 +5,14 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from django_telegram_app.bot.base import Step from django_telegram_app.bot.bot import send_message +from apps.telegram.telegrambot.base import TelegramStep + if TYPE_CHECKING: - from django_telegram_app.bot.base import BaseCommand, TelegramUpdate + from django_telegram_app.bot.base import TelegramUpdate + + from apps.telegram.telegrambot.base import TelegramCommand def prettyprint(data: dict): @@ -17,12 +20,12 @@ def prettyprint(data: dict): return "\n".join([f"{k}={v}" for k, v in data.items()]) -class Confirm(Step): +class Confirm(TelegramStep): """Represent the confirmation step in a Telegram bot command.""" def __init__( self, - command: BaseCommand, + command: TelegramCommand, steps_back: int = 0, unique_id: str | None = None, data_transform_func: Callable[[dict], str] = prettyprint, diff --git a/src/apps/timesheets/telegrambot/steps/select.py b/src/apps/timesheets/telegrambot/steps/select.py index d6873a2..5d9eced 100644 --- a/src/apps/timesheets/telegrambot/steps/select.py +++ b/src/apps/timesheets/telegrambot/steps/select.py @@ -12,23 +12,25 @@ from django.utils import timezone from django.utils.translation import gettext -from django_telegram_app.bot.base import Step from django_telegram_app.bot.bot import DO_NOTHING, send_message from apps.projects.models import Project +from apps.telegram.telegrambot.base import TelegramStep from apps.timesheets.models import Timesheet, TimesheetItem from apps.timesheets.telegrambot.steps._types import OverviewType if TYPE_CHECKING: - from django_telegram_app.bot.base import BaseCommand, TelegramUpdate + from django_telegram_app.bot.base import TelegramUpdate + from apps.telegram.telegrambot.base import TelegramCommand -class SelectDate(Step): + +class SelectDate(TelegramStep): """Represent the date selection step in a Telegram bot command.""" def __init__( self, - command: BaseCommand, + command: TelegramCommand, key: str, initial_date_key: str = "", steps_back: int = 0, @@ -113,7 +115,7 @@ def _get_previous_display_date(self, displayed_date: date): return displayed_date.replace(year=previous_year, month=previous_month) -class SelectDay(Step): +class SelectDay(TelegramStep): """Represent the day selection step in a Telegram bot command.""" def handle(self, telegram_update: "TelegramUpdate"): @@ -194,7 +196,7 @@ def get_keyboard(self, days: list[tuple[Project, TimesheetItem]], data: dict, st return keyboard -class SelectItemType(Step): +class SelectItemType(TelegramStep): """Represent the item type selection step in a Telegram bot command.""" def handle(self, telegram_update: "TelegramUpdate"): @@ -251,7 +253,7 @@ def get_keyboard(self, days: list[tuple[Project, date]], data: dict, start: int, return keyboard -class SelectOverviewType(Step): +class SelectOverviewType(TelegramStep): """Represent the overview type selection step in a Telegram bot command.""" def handle(self, telegram_update: "TelegramUpdate"): @@ -288,7 +290,7 @@ def handle(self, telegram_update: "TelegramUpdate"): ) -class SelectProject(Step): +class SelectProject(TelegramStep): """Represent the project selection step in a Telegram bot command.""" def handle(self, telegram_update: "TelegramUpdate"): @@ -326,12 +328,12 @@ def handle(self, telegram_update: "TelegramUpdate"): ) -class SelectTimesheet(Step): +class SelectTimesheet(TelegramStep): """Represent the timesheet selection step in a Telegram bot command.""" def __init__( self, - command: BaseCommand, + command: TelegramCommand, steps_back: int = 0, filter_kwargs: dict | None = None, order_by: tuple | None = None, @@ -374,7 +376,7 @@ def handle(self, telegram_update: "TelegramUpdate"): ) -class SelectWorkedHours(Step): +class SelectWorkedHours(TelegramStep): """Represent the hours worked selection step in a Telegram bot command.""" def handle(self, telegram_update): diff --git a/src/apps/timesheets/telegrambot/steps/show.py b/src/apps/timesheets/telegrambot/steps/show.py index 522cdf2..4e87839 100644 --- a/src/apps/timesheets/telegrambot/steps/show.py +++ b/src/apps/timesheets/telegrambot/steps/show.py @@ -2,9 +2,9 @@ from typing import TYPE_CHECKING -from django_telegram_app.bot.base import Step from django_telegram_app.bot.bot import send_message +from apps.telegram.telegrambot.base import TelegramStep from apps.timesheets.models import Timesheet from apps.timesheets.telegrambot.steps._types import OverviewType @@ -12,7 +12,7 @@ from django_telegram_app.bot.base import TelegramUpdate -class ShowOverview(Step): +class ShowOverview(TelegramStep): """Represent the show overview step in a Telegram bot command.""" def handle(self, telegram_update: "TelegramUpdate"): diff --git a/src/apps/timesheets/telegrambot/steps/wait.py b/src/apps/timesheets/telegrambot/steps/wait.py index 1c92380..a8b4e63 100644 --- a/src/apps/timesheets/telegrambot/steps/wait.py +++ b/src/apps/timesheets/telegrambot/steps/wait.py @@ -4,17 +4,20 @@ from typing import TYPE_CHECKING -from django_telegram_app.bot.base import Step from django_telegram_app.bot.bot import send_message +from apps.telegram.telegrambot.base import TelegramStep + if TYPE_CHECKING: - from django_telegram_app.bot.base import BaseCommand, TelegramUpdate + from django_telegram_app.bot.base import TelegramUpdate + + from apps.telegram.telegrambot.base import TelegramCommand -class WaitForTime(Step): +class WaitForTime(TelegramStep): """Represent the wait for time input step in a Telegram bot command.""" - def __init__(self, command: BaseCommand, key: str, date_key: str, unique_id: str | None = None): + def __init__(self, command: TelegramCommand, key: str, date_key: str, unique_id: str | None = None): """Initialize the wait for time input step.""" self.key = key self.date_key = date_key @@ -31,7 +34,7 @@ def handle(self, telegram_update: "TelegramUpdate"): ) -class WaitForDescription(Step): +class WaitForDescription(TelegramStep): """Represent the description selection step in a Telegram bot command.""" def handle(self, telegram_update: "TelegramUpdate"): From 9348aa8272e5e0abcfe505f5c27585a8eaa550f1 Mon Sep 17 00:00:00 2001 From: Sonny V Date: Tue, 25 Nov 2025 17:08:00 +0100 Subject: [PATCH 4/7] pip-compile --upgrade --- requirements/dev.txt | 10 +++++----- requirements/main.txt | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index f2623a8..cb75b04 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,7 +4,7 @@ # # pip-compile --extra=dev --output-file=requirements/dev.txt pyproject.toml # -asgiref==3.10.0 +asgiref==3.11.0 # via django astroid==4.0.2 # via pylint @@ -14,7 +14,7 @@ charset-normalizer==3.4.4 # via # reportlab # requests -coverage==7.11.3 +coverage==7.12.0 # via ida (pyproject.toml) dill==0.4.0 # via pylint @@ -22,7 +22,7 @@ django==5.2.8 # via # django-telegram-app # ida (pyproject.toml) -django-telegram-app==0.3.0 +django-telegram-app==1.0.0 # via ida (pyproject.toml) django-types==0.22.0 # via ida (pyproject.toml) @@ -50,11 +50,11 @@ pyright==1.1.407 # via ida (pyproject.toml) python-dotenv==1.2.1 # via ida (pyproject.toml) -reportlab==4.4.4 +reportlab==4.4.5 # via ida (pyproject.toml) requests==2.32.5 # via django-telegram-app -ruff==0.14.5 +ruff==0.14.6 # via ida (pyproject.toml) sqlparse==0.5.3 # via django diff --git a/requirements/main.txt b/requirements/main.txt index 18f32ac..36f3f73 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -4,7 +4,7 @@ # # pip-compile --output-file=requirements/main.txt pyproject.toml # -asgiref==3.10.0 +asgiref==3.11.0 # via django certifi==2025.11.12 # via requests @@ -16,7 +16,7 @@ django==5.2.8 # via # django-telegram-app # ida (pyproject.toml) -django-telegram-app==0.3.0 +django-telegram-app==1.0.0 # via ida (pyproject.toml) envyronment==0.4.0 # via ida (pyproject.toml) @@ -28,7 +28,7 @@ packaging==25.0 # via gunicorn pillow==12.0.0 # via reportlab -reportlab==4.4.4 +reportlab==4.4.5 # via ida (pyproject.toml) requests==2.32.5 # via django-telegram-app From daee4a9c79fea5e1d3e9fb9b0e25d014fd1a251e Mon Sep 17 00:00:00 2001 From: Sonny V Date: Tue, 25 Nov 2025 17:09:50 +0100 Subject: [PATCH 5/7] settings: remove unused DOMAIN_NAME --- src/ida/settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ida/settings.py b/src/ida/settings.py index f73f9b8..e676079 100644 --- a/src/ida/settings.py +++ b/src/ida/settings.py @@ -213,5 +213,3 @@ } TELEGRAM_SETTINGS_MODEL = "telegram.TelegramSettings" - -DOMAIN_NAME = env.read("DJANGO_DOMAIN_NAME", "http://localhost:8000") From 5613a132e509d3a759d7544f299e08a4fca38d2c Mon Sep 17 00:00:00 2001 From: Sonny V Date: Tue, 25 Nov 2025 17:10:49 +0100 Subject: [PATCH 6/7] telegram: use updated command names django-telegram-app renamed their BaseCommand and BaseTelegramCommand to BaseBotCommand and BaseManagementCommand respectively --- src/apps/telegram/__init__.py | 1 + src/apps/telegram/management/base.py | 4 ++-- src/apps/telegram/telegrambot/base.py | 28 +++++++++++++++++++++++++-- src/apps/telegram/tests.py | 4 +--- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/apps/telegram/__init__.py b/src/apps/telegram/__init__.py index e69de29..20d1383 100644 --- a/src/apps/telegram/__init__.py +++ b/src/apps/telegram/__init__.py @@ -0,0 +1 @@ +"""Telegram app.""" diff --git a/src/apps/telegram/management/base.py b/src/apps/telegram/management/base.py index c02238b..9c3eb57 100644 --- a/src/apps/telegram/management/base.py +++ b/src/apps/telegram/management/base.py @@ -6,7 +6,7 @@ from django.utils.translation import override from django_telegram_app.bot.bot import handle_update -from django_telegram_app.management.base import BaseTelegramCommand +from django_telegram_app.management.base import BaseManagementCommand from apps.telegram.models import TelegramSettings from apps.users.models import IdaUser @@ -15,7 +15,7 @@ from django_telegram_app.models import AbstractTelegramSettings -class IdaBaseTelegramCommand(BaseTelegramCommand): +class ManagementCommand(BaseManagementCommand): """Base command for telegram management commands.""" def get_telegram_settings_filter(self): diff --git a/src/apps/telegram/telegrambot/base.py b/src/apps/telegram/telegrambot/base.py index ce1cbc6..681dfdf 100644 --- a/src/apps/telegram/telegrambot/base.py +++ b/src/apps/telegram/telegrambot/base.py @@ -2,12 +2,12 @@ from abc import ABC -from django_telegram_app.bot.base import BaseCommand, Step +from django_telegram_app.bot.base import BaseBotCommand, Step from apps.telegram.models import TelegramSettings -class TelegramCommand(BaseCommand, ABC): +class TelegramCommand(BaseBotCommand, ABC): """Project specific base class for telegram commands.""" settings: TelegramSettings @@ -17,3 +17,27 @@ class TelegramStep(Step, ABC): """Project specific base class for telegram command steps.""" command: TelegramCommand + + def __init__(self, command: TelegramCommand, unique_id: str | None = None, steps_back: int = 0): + """Initialize the telegram step. + + Steps back is configured on the step level since we re-use steps in multiple commands, so the amount of + steps to go back might differ depending on the command. + """ + self.steps_back = steps_back + super().__init__(command, unique_id) + + def maybe_add_previous_button(self, keyboard: list[list[dict]], data: dict, **kwargs): + """Add a previous button if steps_back is set.""" + if self.steps_back <= 0: + return + keyboard.append( + [ + { + "text": "⬅️ Previous step", + "callback_data": self.previous_step_callback( + steps_back=self.steps_back, original_data=data, **kwargs + ), + } + ] + ) diff --git a/src/apps/telegram/tests.py b/src/apps/telegram/tests.py index 7ce503c..420de55 100644 --- a/src/apps/telegram/tests.py +++ b/src/apps/telegram/tests.py @@ -1,3 +1 @@ -from django.test import TestCase - -# Create your tests here. +"""Tests for the Telegram app.""" From 314cc10c6a31e4948a4596256de04d80ca96bed5 Mon Sep 17 00:00:00 2001 From: Sonny V Date: Tue, 25 Nov 2025 17:12:18 +0100 Subject: [PATCH 7/7] timesheets: update management and bot commands for management commands we just update references to BaseManagementCommand. for bot commands we change the calls to create callbacks to the new style where we pass original_data and then kwargs --- .../commands/startcompletetimesheet.py | 4 +- .../management/commands/startregisterwork.py | 4 +- src/apps/timesheets/telegrambot/steps/act.py | 2 +- .../timesheets/telegrambot/steps/confirm.py | 8 +- .../timesheets/telegrambot/steps/select.py | 95 ++++++++----------- src/apps/timesheets/telegrambot/steps/wait.py | 3 +- 6 files changed, 50 insertions(+), 66 deletions(-) diff --git a/src/apps/timesheets/management/commands/startcompletetimesheet.py b/src/apps/timesheets/management/commands/startcompletetimesheet.py index 184088e..8cd9564 100644 --- a/src/apps/timesheets/management/commands/startcompletetimesheet.py +++ b/src/apps/timesheets/management/commands/startcompletetimesheet.py @@ -2,11 +2,11 @@ from django.utils import timezone -from apps.telegram.management.base import IdaBaseTelegramCommand +from apps.telegram.management.base import ManagementCommand from apps.timesheets.telegrambot.commands.completetimesheet import Command as CompleteTimesheetCommand -class Command(IdaBaseTelegramCommand): +class Command(ManagementCommand): """Start the CompleteTimesheet command.""" help = "Start the CompleteTimesheet command to let users complete their timesheets." diff --git a/src/apps/timesheets/management/commands/startregisterwork.py b/src/apps/timesheets/management/commands/startregisterwork.py index 90fe72f..b7f4691 100644 --- a/src/apps/timesheets/management/commands/startregisterwork.py +++ b/src/apps/timesheets/management/commands/startregisterwork.py @@ -2,11 +2,11 @@ from django.utils import timezone -from apps.telegram.management.base import IdaBaseTelegramCommand +from apps.telegram.management.base import ManagementCommand from apps.timesheets.telegrambot.commands.registerwork import Command as RegisterWorkCommand -class Command(IdaBaseTelegramCommand): +class Command(ManagementCommand): """Start a RegisterWork command.""" help = "Start a RegisterWork command to let users register their work hours." diff --git a/src/apps/timesheets/telegrambot/steps/act.py b/src/apps/timesheets/telegrambot/steps/act.py index fe6a159..6f85c8f 100644 --- a/src/apps/timesheets/telegrambot/steps/act.py +++ b/src/apps/timesheets/telegrambot/steps/act.py @@ -36,7 +36,7 @@ def handle(self, telegram_update: "TelegramUpdate"): combined_datetime = datetime.combine(date_part, time_part) data[self.time_key] = combined_datetime.isoformat() data.pop(self.date_key) - telegram_update.callback_data = self.next_step_callback(**data) + telegram_update.callback_data = self.next_step_callback(data) return self.command.next_step(self.name, telegram_update) def _validate_time_format(self, time_str: str): diff --git a/src/apps/timesheets/telegrambot/steps/confirm.py b/src/apps/timesheets/telegrambot/steps/confirm.py index a121a08..83b21ed 100644 --- a/src/apps/timesheets/telegrambot/steps/confirm.py +++ b/src/apps/timesheets/telegrambot/steps/confirm.py @@ -37,17 +37,15 @@ def __init__( def handle(self, telegram_update: "TelegramUpdate"): """Show the confirmation step.""" data = self.get_callback_data(telegram_update) - data_confirmed = dict(data, confirmed=True) - confirmation_yes = self.next_step_callback(**data_confirmed) - data_declined = dict(data, confirmed=False) - confirmation_no = self.cancel_callback(**data_declined) + confirmation_yes = self.next_step_callback(data, confirmed=True) + confirmation_no = self.cancel_callback(data, confirmed=False) keyboard = [ [{"text": "✅ Ok", "callback_data": confirmation_yes}], [{"text": "❌ Cancel", "callback_data": confirmation_no}], ] - self.maybe_add_previous_button(keyboard, **data) + self.maybe_add_previous_button(keyboard, data) message = f"{self.command.get_name()} with the following data?\n{self.data_transform_func(data)}" send_message( diff --git a/src/apps/timesheets/telegrambot/steps/select.py b/src/apps/timesheets/telegrambot/steps/select.py index 5d9eced..8f25baf 100644 --- a/src/apps/timesheets/telegrambot/steps/select.py +++ b/src/apps/timesheets/telegrambot/steps/select.py @@ -47,13 +47,13 @@ def handle(self, telegram_update: "TelegramUpdate"): now = timezone.now() display_date = self._get_display_date(data, now) - data_previous = {**data, self.key: self._get_previous_display_date(display_date)} - data_next = {**data, self.key: self._get_next_display_date(display_date)} + prev_kw = {self.key: self._get_previous_display_date(display_date)} + next_kw = {self.key: self._get_next_display_date(display_date)} keyboard = [] header = [ - {"text": "<<", "callback_data": self.current_step_callback(**data_previous)}, + {"text": "<<", "callback_data": self.current_step_callback(data, **prev_kw)}, {"text": f"{str(display_date.month).zfill(2)}/{display_date.year}", "callback_data": DO_NOTHING}, - {"text": ">>", "callback_data": self.current_step_callback(**data_next)}, + {"text": ">>", "callback_data": self.current_step_callback(data, **next_kw)}, ] keyboard.append(header) @@ -70,11 +70,11 @@ def handle(self, telegram_update: "TelegramUpdate"): text = str(day).zfill(2) if selected_date == now.date(): text = f"({text})" - data_dict = {**data, self.key: selected_date} - row.append({"text": text, "callback_data": self.next_step_callback(**data_dict)}) + kwargs = {self.key: selected_date} + row.append({"text": text, "callback_data": self.next_step_callback(data, **kwargs)}) keyboard.append(row) - self.maybe_add_previous_button(keyboard, **data) + self.maybe_add_previous_button(keyboard, data) reply_markup = {"inline_keyboard": keyboard} send_message( @@ -135,7 +135,7 @@ def handle(self, telegram_update: "TelegramUpdate"): self._maybe_add_pagination_buttons(keyboard, days, data, current_page, end) - self.maybe_add_previous_button(keyboard, **data) + self.maybe_add_previous_button(keyboard, data) reply_markup = {"inline_keyboard": keyboard} send_message( @@ -155,11 +155,11 @@ def get_keyboard(self, days: list[tuple], data: dict, start: int, end: int): def _maybe_add_pagination_buttons(self, keyboard: list, days: list, data: dict, current_page: int, end: int): if current_page > 1: - data_back = dict(data, current_page=current_page - 1) - keyboard.append([{"text": "⬅️ Back", "callback_data": self.current_step_callback(**data_back)}]) + callback_back = self.current_step_callback(data, current_page=current_page - 1) + keyboard.append([{"text": "⬅️ Back", "callback_data": callback_back}]) if len(days) > end: - data_next = dict(data, current_page=current_page + 1) - keyboard.append([{"text": "➡️ Next", "callback_data": self.current_step_callback(**data_next)}]) + callback_next = self.current_step_callback(data, current_page=current_page + 1) + keyboard.append([{"text": "➡️ Next", "callback_data": callback_next}]) class SelectExistingDay(SelectDay): @@ -182,16 +182,11 @@ def get_keyboard(self, days: list[tuple[Project, TimesheetItem]], data: dict, st """Get the keyboard for the given days and data.""" keyboard = [] for project, item in days[start:end]: - data_day = dict( + callback_next = self.next_step_callback( data, start_date=item.date, project_id=project.pk, project_name=project.name, item_pk=item.pk ) keyboard.append( - [ - { - "text": f"{project}: {item.date} ({item.worked_hours}h)", - "callback_data": self.next_step_callback(**data_day), - } - ] + [{"text": f"{project}: {item.date} ({item.worked_hours}h)", "callback_data": callback_next}] ) return keyboard @@ -204,28 +199,21 @@ def handle(self, telegram_update: "TelegramUpdate"): data = self.get_callback_data(telegram_update) keyboard = [] for item_type in TimesheetItem.ItemType: - data_item = dict(data, item_type=item_type.value, item_type_label=item_type.label) - keyboard.append( - [ - { - "text": str(item_type.label), # Needs str cast for lazy translation objects - "callback_data": self.next_step_callback(**data_item), - } - ] - ) + item_label = str(item_type.label) # Needs str cast for lazy translation objects + next_callback = self.next_step_callback(data, item_type=item_type.value, item_type_label=item_type.label) + keyboard.append([{"text": item_label, "callback_data": next_callback}]) # Add the infer item type - data_infer = dict(data, item_type=0, item_type_label="Inferred") keyboard.append( [ { "text": "Inferred", - "callback_data": self.next_step_callback(**data_infer), + "callback_data": self.next_step_callback(data, item_type=0, item_type_label="Inferred"), } ] ) - self.maybe_add_previous_button(keyboard, **data) + self.maybe_add_previous_button(keyboard, data) send_message( "Select the item type:", @@ -248,8 +236,10 @@ def get_keyboard(self, days: list[tuple[Project, date]], data: dict, start: int, """Get the keyboard for the given days and data.""" keyboard = [] for project, day in days[start:end]: - data_day = dict(data, start_date=day, project_id=project.pk, project_name=project.name) - keyboard.append([{"text": f"{project}: {day}", "callback_data": self.next_step_callback(**data_day)}]) + callback_day = self.next_step_callback( + data, start_date=day, project_id=project.pk, project_name=project.name + ) + keyboard.append([{"text": f"{project}: {day}", "callback_data": callback_day}]) return keyboard @@ -264,23 +254,23 @@ def handle(self, telegram_update: "TelegramUpdate"): [ { "text": "Summary Overview", - "callback_data": self.next_step_callback(**data, overview_type=OverviewType.SUMMARY.value), + "callback_data": self.next_step_callback(data, overview_type=OverviewType.SUMMARY.value), } ], [ { "text": "Detailed Overview", - "callback_data": self.next_step_callback(**data, overview_type=OverviewType.DETAILED.value), + "callback_data": self.next_step_callback(data, overview_type=OverviewType.DETAILED.value), } ], [ { "text": "Holidays Overview", - "callback_data": self.next_step_callback(**data, overview_type=OverviewType.HOLIDAYS.value), + "callback_data": self.next_step_callback(data, overview_type=OverviewType.HOLIDAYS.value), } ], ] - self.maybe_add_previous_button(keyboard, **data) + self.maybe_add_previous_button(keyboard, data) send_message( "Which type of overview would you like to see?", @@ -307,18 +297,17 @@ def handle(self, telegram_update: "TelegramUpdate"): data = self.get_callback_data(telegram_update) if len(projects) == 1: - data["project_id"] = projects[0].pk - data["project_name"] = str(projects[0]) - telegram_update.callback_data = self.next_step_callback(**data) + project = projects[0] + callback_next = self.next_step_callback(data, project_id=project.pk, project_name=str(project)) + telegram_update.callback_data = callback_next return self.command.next_step(self.name, telegram_update) keyboard = [] for project in projects: - data["project_id"] = project.pk - data["project_name"] = str(project) - keyboard.append([{"text": str(project), "callback_data": self.next_step_callback(**data)}]) + callback_next = self.next_step_callback(data, project_id=project.pk, project_name=str(project)) + keyboard.append([{"text": str(project), "callback_data": callback_next}]) - self.maybe_add_previous_button(keyboard, **data) + self.maybe_add_previous_button(keyboard, data) send_message( "Select a project:", @@ -355,18 +344,17 @@ def handle(self, telegram_update: "TelegramUpdate"): data = self.get_callback_data(telegram_update) if len(timesheets) == 1: - data["timesheet_id"] = timesheets[0].pk - data["timesheet_name"] = str(timesheets[0]) - telegram_update.callback_data = self.next_step_callback(**data) + timesheet = timesheets[0] + next_callback = self.next_step_callback(data, timesheet_id=timesheet.pk, timesheet_name=str(timesheet)) + telegram_update.callback_data = next_callback return self.command.next_step(self.name, telegram_update) keyboard = [] for timesheet in timesheets: - data["timesheet_id"] = timesheet.pk - data["timesheet_name"] = str(timesheet) - keyboard.append([{"text": str(timesheet), "callback_data": self.next_step_callback(**data)}]) + next_callback = self.next_step_callback(data, timesheet_id=timesheet.pk, timesheet_name=str(timesheet)) + keyboard.append([{"text": str(timesheet), "callback_data": next_callback}]) - self.maybe_add_previous_button(keyboard, **data) + self.maybe_add_previous_button(keyboard, data) send_message( "Select a timesheet:", @@ -385,10 +373,9 @@ def handle(self, telegram_update): options = {"Full day (8h)": 8, "Half day (4h)": 4, "Holiday (0h)": 0} keyboard = [] for key, value in options.items(): - data_duration = dict(data, duration=value) - keyboard.append([{"text": key, "callback_data": self.next_step_callback(**data_duration)}]) + keyboard.append([{"text": key, "callback_data": self.next_step_callback(data, duration=value)}]) - self.maybe_add_previous_button(keyboard, **data) + self.maybe_add_previous_button(keyboard, data) reply_markup = {"inline_keyboard": keyboard} send_message( diff --git a/src/apps/timesheets/telegrambot/steps/wait.py b/src/apps/timesheets/telegrambot/steps/wait.py index a8b4e63..0073372 100644 --- a/src/apps/timesheets/telegrambot/steps/wait.py +++ b/src/apps/timesheets/telegrambot/steps/wait.py @@ -41,8 +41,7 @@ def handle(self, telegram_update: "TelegramUpdate"): """Prompt the user to input a description or select no description.""" data = self.get_callback_data(telegram_update) self.add_waiting_for("description", data) - data_dict = dict(data, description="") - keyboard = [[{"text": "No description.", "callback_data": self.next_step_callback(**data_dict)}]] + keyboard = [[{"text": "No description.", "callback_data": self.next_step_callback(data, description="")}]] send_message( "Send the description (or select 'No description'):", self.command.settings.chat_id,