diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..34ff404 Binary files /dev/null and b/.coverage differ diff --git a/.github/workflows/ci-team-34.yaml b/.github/workflows/ci-team-34.yaml new file mode 100644 index 0000000..004fa2d --- /dev/null +++ b/.github/workflows/ci-team-34.yaml @@ -0,0 +1,58 @@ +name: Team 34 CI Pipeline + +on: [push, pull_request] + +env: + POETRY_VERSION: "1.8.3" + DEFAULT_PY_VERSION: "3.11" + +jobs: + lint: + runs-on: ubuntu-latest + steps: + + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: ${{ env.DEFAULT_PY_VERSION }} + + - name: Linting + env: + RUFF_OUTPUT_FORMAT: github + run: | + pip install ruff + ruff check bot + + test-with-coverage: + needs: lint + runs-on: ubuntu-latest + steps: + + - name: Checkout code + uses: actions/checkout@v2 + + - name: Install Poetry + shell: bash + run: pipx install poetry==${{ env.POETRY_VERSION }} + + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: ${{ env.DEFAULT_PY_VERSION }} + + - name: Install dependencies + run: poetry install + + - name: Run tests with coverage + run: | + poetry run coverage run -m pytest tests + poetry run coverage report + + - name: Upload coverage report + uses: actions/upload-artifact@v2 + with: + name: coverage-report + path: coverage.xml diff --git a/.vscode/settings.json b/.vscode/settings.json index fc4c9b2..288f5f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -42,4 +42,4 @@ }, "terminal.integrated.scrollback": 100000, "workbench.sideBar.location": "right" -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f240c85 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,313 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [unreleased] + +### ๐Ÿš€ Features + +- *(flake)* Add flake +- *(flake)* Add direnv config +- *(gitignore)* Ignore direnv and mypy directories +- *(flake)* Package the bot +- *(docker)* Add dockerfile +- *(docker)* Add compose file +- *(bot)* Add cli +- *(bot)* Add the 'get_subscribers' command +- *(bot)* Schedule since the specified date +- *(flake)* Use treefmt-nix +- *(flake)* Scripts +- *(bot, readme)* Provide an example for '/set_meeting_time' +- *(license)* Add mit license +- *(bot)* Add ru messages +- *(requirements)* Init +- *(requirements)* Add meeting questions +- *(readme)* Add i18n section +- *(scripts)* Add a script to generate locales files +- *(locales)* Add ru and en +- *(dockerfile)* Copy locales +- *(readme)* Motivation +- *(requirements)* Add glossary +- *(requirements)* Define command types +- *(requirements)* Add dialog scenarios +- *(scripts)* Add script to start a bot +- *(requirements)* Add references +- *(requirements)* Add top-level section name +- *(poetry)* Add Babel as a dev dependency +- *(configuration)* Draft a roles and responsibilities doc +- *(policies-processes-procedures)* Init +- *(policies-processes-procedures)* Add processes diagram +- *(nodejs)* Add package.json and package-lock.json +- *(gitignore)* Ignore nodejs directory +- *(sprint-docs)* Init in Russian +- *(git-workflow-process)* Init +- *(vscode)* Add recommended extensions +- *(vscode)* Add settings +- *(vscode)* Add Live Share to recommended extensions +- *(snippets)* Init snippet for receiving reminders +- *(scenarios)* Init a scenario +- *(snippets)* Add handle reminder period change state +- *(glossary)* Init +- *(scenarios)* Add a link to glossary +- *(scenarios)* Add a link to glossary +- *(scenarios)* Init a scenario template +- *(git-workflow-process)* Init +- *(vscode)* Add recommended extensions +- *(vscode)* Add settings +- *(vscode)* Add Live Share to recommended extensions +- *(snippets)* Init snippet for receiving reminders +- *(scenarios)* Init a scenario +- *(snippets)* Add handle reminder period change state +- *(glossary)* Init +- *(scenarios)* Add a link to glossary +- *(scenarios)* Add a link to glossary +- *(sprint)* Add a link to policy, process, procedure explanation +- *(sprint)* Add an Issues policy +- *(topics)* Store separate chat states for supergroup topics +- *(topics)* Implement per-topic meeting messages +- *(topics)* Send reminders w.r.t topics +- *(scenarios)* Add set_personal_meetings_time scenario +- *(scenarios)* Add the definition of a "period" +- *(docs)* Add glossary +- *(vscode)* Add markdown sort to sort the glossary +- *(issues)* Specify when a task is completed +- *(issues)* Add a scenario issue form +- *(issues)* Disable blank issues +- *(issues)* Add script to generate issue forms +- *(issues)* Add templates +- *(flake)* Add jinja2-cli to devshell tools +- *(glossary)* Define Actor +- *(glossary)* Add cross-references +- *(docker)* Enable environment variables for authentication +- *(bot)* Use mongo service name, username and password from env variables +- *(topics)* Store separate chat states for supergroup topics +- *(topics)* Send reminders w.r.t topics +- Change default callback separator symbol from ':' to '|' to be able to work with intervals +- Edit empty schedule to be dictionary instead of list +- Edit mongodb config to return datetime in offset-aware state +- Edit filters to work with callbacks +- Add group of states for interval editing +- Edit interval model to store times in UTC +- Modify keyboards for new callbacks and intervals +- Add fields to chat user document to handle interval editing +- Edit error messages to more use friendly form +- Connect al operations with mongodb and rework interval editng +- *(CI)* Add file for CI settings in Github Actions +- *(CI)* Update ci-team-34.yaml +- *(CI)* Update ci-team-34.yaml +- *(linter)* Added ruff(linter) +- *(keyboards)* Add file for keyboards +- *(schedule)* Add file for handling user working time setting +- *(commands)* Add /set_personal_working_time to commands and modify handlers file for work time settings +- *(intervals)* Add files to store callback dataclasses and pydantic models for time intervals +- *(callbacks)* Add callbacks for intervals editing and weekday toggling +- *(intervals)* Add base models for time intervals and weekdays +- *(keyboards)* Add callbacks and inline queries to buttons +- *(schedule)* Add all necessary handlers for menu with schedule +- *(intervals)* Add interval validation and exceptions +- *(messages)* Add messages if user entered invalid interval +- *(keyboards)* Add keyboard markup for wrong interval input +- *(schedule)* Add handlers for wrong input and reentering intervals +- *(schedule)* Add default intervals handling +- *(db)* Add fields in db +- *(middlewares)* Add middlewares file and connected them with dispatcher +- *(topics)* Store separate chat states for supergroup topics +- *(topics)* Implement per-topic meeting messages +- *(topics)* Send reminders w.r.t topics +- *(intervals)* Edit Interval class to use datetime instead of datetime.time +- *(intervals)* Modified merge intervals function to merge only unique intervals +- *(fsm)* Add file for storing custom FSM states +- *(callbacks)* Change default callback separator symbol from ':' to '|' to be able to work with intervals +- *(constants)* Edit empty schedule to be dictionary instead of list +- *(db)* Edit mongodb config to return datetime in offset-aware state +- *(filters)* Edit filters to work with callbacks +- *(fsm)* Add group of states for interval editing +- *(intervals)* Edit interval model to store times in UTC +- *(keyboards)* Modify keyboards for new callbacks and intervals +- *(db)* Add fields to chat user document to handle interval editing +- *(messages)* Edit error messages to more use friendly form +- *(schedule)* Connect all operations with mongodb and rework interval editng +- *(schedule)* Add timezone changing support +- *(commands)* Edit command name for schedule editing +- *(keyboards)* Add missing keyboards from scenario +- *(messages)* Add missing messages from scenario +- *(schedule)* Add logic to work with default and personal schedule + keyboards for interval editing + Cancel/Save layout +- *(intervals)* Add function to ckeck if time is in schedule or not +- *(meetings)* Add check if user is working by his working hours +- *(schedule)* Remove legacy command for setting working days +- *(menu)* Add all commands' names and descriptions to Telegram command menu +- *(intervals)* Add uuid for each interval and add function for shift recalculating + +### ๐Ÿ› Bug Fixes + +- *(bot)* Make it work +- *(bot)* Remove unused imports +- *(bot, readme)* Bot description +- *(bot)* Help message +- *(bot)* Improve wording in the description +- *(description)* Use 'Daily Scrum' instead of 'scrum stand-up' in descriptions +- *(bot)* Move files +- *(poetry)* Remove unused dependencies +- *(bot)* Rename a module because python doesn't like types.py +- *(flake)* Add more 'follows' +- *(poetry)* Remove old package from dependencies +- *(bot, readme)* Use a data directory +- *(bot)* Strip spaces +- *(bot, docker)* Use mongo, add basic time handling +- *(bot)* Switch to ISO 8601 time, rename 'state' -> 'chat_state' +- *(bot)* Improve messages for the 'unsubscribe' command +- *(bot)* Schedule meetings no earlier than on the set date and time +- *(flake)* Remove poetry2nix +- *(bot)* Wording +- *(bot)* Don't run a job after the job has expired long ago +- *(poetry)* Add babel +- *(scripts)* Run command via poetry +- *(readme)* Bot description +- *(requirements)* Update non-functional requirements +- *(requirements)* Section name +- *(requirements)* I18n +- *(requirements)* Commands +- *(readme)* Add motivation point +- *(requirements)* Wording +- *(bot)* Move files +- *(docker)* Update bot directory +- *(locales)* Update bot directory +- *(poetry)* Update bot directory +- *(flake)* Update script +- *(requirements)* Update command names +- *(readme)* Bot link +- *(bot)* Sample time +- *(bot)* Handle missing users +- *(readme)* Update project name, description, messages +- *(readme)* Instructions for running +- *(bot)* Wording +- *(readme)* Wording +- *(bot)* Add blank lines in messages +- *(bot)* Wording +- *(configuration)* Wording +- *(configuration)* Anchors +- *(configuration)* Links +- *(roles-and-responsibilities)* Sort members in the lexicographic order +- *(roles-and-responsibilities)* Remove todo +- *(roles-and-responsibilities)* Use lists in Reponsible people sections +- *(roles-and-responsibilities)* Update Task activity sections +- *(roles-and-responsibilities)* Remove the `GitHub Issues Management` responsibility +- *(roles-and-responsibilities)* Combine the Mini App responsibility with Backend and Frontend responsibilities +- *(roles-and-responsibilities)* Format the file +- *(roles-and-responsibilities)* Replace "Task characteristics" with "Task activity" +- *(roles-and-responsibilities)* Improve wording in "Task activity" sections +- *(scenarios)* Rename "Person" -> "User" +- *(scenarios)* Wording +- *(scenarios)* Wording +- *(snippets)* Simplify wording +- *(snippets)* Simplify wording +- *(snippets)* Rename "Person" -> "User" +- *(snippets)* Use more precise terminology +- *(snippets)* Use more precise terminology +- *(scenarios)* Improve wording +- *(scenarios)* Improve wording +- *(scenarios)* Move the definition of uppercase words to glossary +- *(scenarios)* Update scenario template +- *(scenarios)* Rename "Person" -> "User" +- *(scenarios)* Wording +- *(scenarios)* Wording +- *(snippets)* Simplify wording +- *(snippets)* Simplify wording +- *(snippets)* Rename "Person" -> "User" +- *(snippets)* Use more precise terminology +- *(snippets)* Use more precise terminology +- *(scenarios)* Improve wording +- *(scenarios)* Improve wording +- *(scenarios)* Term formatting +- *(configuration)* Remove old file +- *(reminder)* Make message babel-friendly +- *(configs)* Revert accidental change in settings configuration file +- *(scenarios)* Optimize set_personal_meetings_time tapping + and changing timeslot +- *(scenarios)* Change command name +- *(scenarios)* Describe bot buttons in the message about wrong format of an interval +- *(scenarios)* Remove a redundant quote +- *(scenarios)* Replace "period" with "interval" +- *(glossary)* Explain the UPPERCASE words more prominently +- *(glossary)* Move to the `docs` directory +- *(scenarios)* Links to the glossary +- *(scenarios)* Remove old glossary +- *(glossary)* Use sections for definitions +- *(issues)* Update the template +- *(issues)* Rename the template +- *(issues)* Rewrite a list +- *(issues)* Update the template +- *(issues)* Remove an unnecessary template +- *(issues)* Improve wording +- *(issues)* Improve grammar +- *(issues)* Improve wording +- *(issues)* Rewrite using passive voice +- *(issues)* Sentence order +- *(issues)* Formatting +- *(issues)* Improve term +- *(issues)* Default title +- *(issues)* Remove templates +- *(issues)* Template values +- *(issues)* Use a space for the form title +- *(issues)* Use variables in form titles +- *(issues)* Add an assignee for scenario issues +- *(issues)* Add default labels for scenario issues +- *(issues)* Change quote pairs +- *(issues)* Remove full stops +- *(issues)* Don't please +- *(sprint)* Use the main glossary +- *(glossary)* Add missing links +- *(issues)* Improve wording in scenario issue forms +- *(issues)* Explain the rules for the Task issue title +- *(issues)* Wording +- *(issues)* Update default labels +- *(docker)* Make the bot service depend on the mongodb service +- *(readme)* Update .env example to mention username and password +- *(configs)* Revert accidental change in settings configuration file +- *(CI)* Add dependencies for testing in yaml file +- *(CI)* Add dependencies for testing in CI +- *(CI)* Add motor dependency for testing in CI +- *(intervals)* Made some minor changes to make classes simplier +- *(reminders)* Fix bug with phantom notifications (again) +- *(configs)* Revert accidental change in settings configuration file +- *(reminders)* Fix indents in user block message and add text to notification message +- *(intervals)* Rework algorithm for interval merging + +### ๐Ÿšœ Refactor + +- *(bot)* Use a constant for week days +- *(bot)* Move bot message to constants +- *(bot)* Move messages to a separate module +- *(bot)* Change 'standup' to 'meeting' in more places +- *(bot)* Use a class for constants directly +- *(bot)* Move bot code to a separate module +- *(bot)* Simplify filters +- *(bot)* Use classes for command names and descriptions +- *(bot)* Construct messages using constants, organize code to support basic i18n +- *(readme)* Add Develop section +- *(typing)* Adjust topic_id type hint +- *(linter)* Fix linter errors +- *(tests)* Updated .coverage +- *(merge)* Resolved merge conflict +- *(typing)* Adjust topic_id type hint +- *(linter)* Delete unused imports and other errors caught by linter +- *(schedule)* Edit command and function names, refactor field order in db +- *(merge)* Resolved merge conflicts +- *(tests)* Refactored code according to the linter + +### ๐Ÿงช Testing + +- *(unit)* Added unit tests for 3 functions + script that runs tests +- *(tool)* Added coverage tool +- *(unit)* Renamed a test +- *(unit)* Added tests for Interaval class + +### โš™๏ธ Miscellaneous Tasks + +- *(locales)* Update +- *(issues)* Generate issue forms + +### Init + +- *(all)* Start development + + diff --git a/bot/bot.py b/bot/bot.py index b3c7f32..82b70c8 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -3,6 +3,7 @@ from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode +from aiogram.types import BotCommand from aiogram.utils.i18n import I18n from aiogram.utils.i18n.middleware import FSMI18nMiddleware from apscheduler.jobstores.memory import MemoryJobStore @@ -10,10 +11,11 @@ from pytz import utc from . import db, handlers -from .commands import BotCommands from .constants import jobstore +from .commands import bot_command_names, bot_command_descriptions from .custom_types import ChatId, SendMessage from .meeting import schedule_meeting +from .middlewares import GroupCommandFilterMiddleware from .settings import Settings from .state import ChatState @@ -40,10 +42,18 @@ async def restore_scheduled_jobs( ) -async def on_startup(): - bot_commands = [ - BotCommands() - ] +async def set_default_commands(bot: Bot): + commands = [] + descriptions = bot_command_descriptions() + + attribute_names = vars(bot_command_names).keys() + for attr in attribute_names: + cmd_name = getattr(bot_command_names, attr) + cmd_descr = getattr(descriptions, attr) + cmd = BotCommand(command=cmd_name, description=cmd_descr) + commands.append(cmd) + + await bot.set_my_commands(commands=commands) async def main(settings: Settings) -> None: @@ -63,6 +73,8 @@ async def main(settings: Settings) -> None: async def send_message(chat_id: ChatId, message: str, message_thread_id: Optional[int] = None): return await bot.send_message(chat_id=chat_id, text=message, message_thread_id=message_thread_id) + dp.update.outer_middleware(GroupCommandFilterMiddleware()) + scheduler = init_scheduler(settings=settings) await restore_scheduled_jobs(scheduler=scheduler, send_message=send_message) @@ -73,3 +85,5 @@ async def send_message(chat_id: ChatId, message: str, message_thread_id: Optiona await bot.delete_webhook(drop_pending_updates=True) await dp.start_polling(bot) + + await set_default_commands(bot=bot) diff --git a/bot/callbacks.py b/bot/callbacks.py new file mode 100644 index 0000000..53bb375 --- /dev/null +++ b/bot/callbacks.py @@ -0,0 +1,14 @@ +from uuid import UUID + +from aiogram.filters.callback_data import CallbackData + + +class IntervalCallback(CallbackData, prefix="interval", sep="|"): + weekday: str + interval: UUID + action: str # add, remove, edit + + +class WeekdayCallback(CallbackData, prefix="weekday"): + weekday: str + action: str # toggle diff --git a/bot/commands.py b/bot/commands.py index a7bf67f..f2417bb 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -1,8 +1,6 @@ from aiogram.utils.i18n import gettext as _ from pydantic import BaseModel -from .language import Language - class BotCommands(BaseModel): # global commands @@ -15,10 +13,11 @@ class BotCommands(BaseModel): set_meetings_time_zone: str set_meetings_time: str set_meetings_days: str + set_default_working_time: str # personal settings join: str skip: str - set_personal_meetings_days: str + set_working_hours: str set_reminder_period: str join_today: str skip_today: str @@ -43,10 +42,11 @@ class BotCommandNames(BotCommands): set_meetings_time_zone="set_meetings_time_zone", set_meetings_time="set_meetings_time", set_meetings_days="set_meetings_days", + set_default_working_time="set_default_working_time", # personal settings join="join", skip="skip", - set_personal_meetings_days="set_personal_meetings_days", + set_working_hours="set_working_hours", set_reminder_period="set_reminder_period", join_today="join_today", skip_today="skip_today", @@ -74,10 +74,11 @@ def bot_command_descriptions() -> BotCommandDescriptions: set_meetings_time_zone=_("Set meetings time zone."), set_meetings_time=_("Set meetings time."), set_meetings_days=_("Set meetings days."), + set_default_working_time=_("Set default working interval for weekday."), # personal settings join=_("Join meetings."), skip=_("Skip meetings."), - set_personal_meetings_days=_("Set the days when you can join meetings."), + set_working_hours=_("Set your working schedule."), set_reminder_period=_("Set how often you'll be reminded about unanswered questions."), join_today=_("Join only today's meeting."), skip_today=_("Skip only today's meeting."), diff --git a/bot/constants.py b/bot/constants.py index 5e7703e..1d78b4c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,4 +1,4 @@ -from aiogram import html +from .intervals import DaySchedule ENCODING = "utf-8" @@ -15,6 +15,10 @@ class AppCommands: iso8601 = "ISO 8601" +interval_format = "HH:MM or HH.MM" + +sample_interval = "9:00 - 17:00" + datetime_time_format = "%H:%M %Y-%m-%d" day_of_week = "0-6" @@ -26,3 +30,11 @@ class AppCommands: } sample_time = "2024-06-03T14:00:00+03:00" + +default_time_zone = "Europe/Moscow" + +days_array = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + +empty_schedule = {day: DaySchedule(name=day) for day in days_array} + +default_schedule = empty_schedule diff --git a/bot/custom_types.py b/bot/custom_types.py index 753fee9..5ccc82d 100644 --- a/bot/custom_types.py +++ b/bot/custom_types.py @@ -1,9 +1,7 @@ from typing import Protocol, Optional -from aiogram import Bot from aiogram.types import Message -from . import state from .chat import ChatId from .state import ChatState diff --git a/bot/db.py b/bot/db.py index e4f8d96..4dc8b98 100644 --- a/bot/db.py +++ b/bot/db.py @@ -8,7 +8,10 @@ async def main(settings: Settings): # Create Motor client client = AsyncIOMotorClient( - f"{settings.mongo_host}://{settings.mongo_username}:{settings.mongo_password}@{settings.mongo_host}:{settings.mongo_port}" + # TODO enable user and password + # f"mongodb://{settings.mongo_username}:{settings.mongo_password}@{settings.mongo_host}:{settings.mongo_port}" + f"mongodb://{settings.mongo_host}:{settings.mongo_port}", + tz_aware=True ) db = client["bot_states"] diff --git a/bot/filters.py b/bot/filters.py index 9f32c35..8b804c6 100644 --- a/bot/filters.py +++ b/bot/filters.py @@ -1,5 +1,5 @@ from aiogram.filters import Filter -from aiogram.types import Message, User +from aiogram.types import Message, CallbackQuery, User from .state import load_state @@ -13,8 +13,8 @@ async def __call__(self, message: Message): class HasMessageUserUsername(Filter): - async def __call__(self, message: Message): - match user := message.from_user: + async def __call__(self, obj: Message | CallbackQuery): + match user := obj.from_user: case User(): match user.username: case str(): @@ -23,9 +23,14 @@ async def __call__(self, message: Message): class HasChatState(Filter): - async def __call__(self, message: Message): - chat_state = await load_state(message.chat.id, message.message_thread_id) - return {"chat_state": chat_state} + async def __call__(self, obj: Message | CallbackQuery): + match obj: + case Message(): + chat_state = await load_state(obj.chat.id, obj.message_thread_id) + return {"chat_state": chat_state} + case CallbackQuery(): + chat_state = await load_state(obj.message.chat.id, obj.message.message_thread_id) + return {"chat_state": chat_state} class IsReplyToMeetingMessage(Filter): diff --git a/bot/fsm_states.py b/bot/fsm_states.py new file mode 100644 index 0000000..11ca2b9 --- /dev/null +++ b/bot/fsm_states.py @@ -0,0 +1,5 @@ +from aiogram.filters.state import StatesGroup, State + + +class IntervalEditingState(StatesGroup): + EnterNewInterval = State() diff --git a/bot/handlers.py b/bot/handlers.py index 8325793..285e9fa 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -9,12 +9,12 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from .commands import bot_command_names -from .constants import (day_of_week_to_num, day_of_week_pretty, iso8601, - sample_time, time_url) +from .constants import (day_of_week_pretty, iso8601, sample_time, time_url, day_of_week_to_num) from .custom_types import SendMessage from .filters import HasChatState, HasMessageText, HasMessageUserUsername, IsReplyToMeetingMessage from .meeting import schedule_meeting from .reminder import update_reminders +from .work_time import handle_working_hours from .messages import make_help_message from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm @@ -34,6 +34,10 @@ def make_router(scheduler: AsyncIOScheduler, send_message: SendMessage, bot: Bot scheduler=scheduler, send_message=send_message, router=router, bot=bot ) + handle_working_hours( + scheduler=scheduler, send_message=send_message, router=router, bot=bot + ) + handle_info_commands( scheduler=scheduler, send_message=send_message, router=router ) @@ -50,6 +54,14 @@ def handle_global_commands( ): @router.message(Command(bot_command_names.start), HasChatState()) async def start(message: Message, chat_state: ChatState): + if message.chat.type == "group": + await message.answer( + "Unfortunately, only supergroups and private chats are supported. " + "Please promote this group to a supergroup by enabling the history of messages for new members " + "or by enabling topics." + ) + return + await get_help(message=message, chat_state=chat_state) # Register user if it is personal message @@ -118,7 +130,7 @@ async def set_meetings_time( start_date=html.bold(meeting_time.strftime("%Y-%m-%d")), ) ) - except Exception as e: + except Exception: await message.reply( dedent( _( @@ -126,7 +138,7 @@ async def set_meetings_time( Please write the meetings time in the {iso8601} format with an offset relative to the UTC time zone. You can calculate the time on the site {time_url}. - + Example: /{set_meetings_time} {sample_time} @@ -238,7 +250,7 @@ async def set_personal_meetings_days( meeting_days=html.bold(", ".join(day_tokens)) ) ) - except Exception as e: + except Exception: await message.reply( dedent( _( diff --git a/bot/intervals.py b/bot/intervals.py new file mode 100644 index 0000000..95a45f1 --- /dev/null +++ b/bot/intervals.py @@ -0,0 +1,245 @@ +from uuid import UUID, uuid4 +from typing import List, Tuple, Dict +from datetime import datetime, time, timedelta +from zoneinfo import ZoneInfo + +from pydantic import BaseModel +from pytz import timezone, UnknownTimeZoneError + + +class IntervalException(Exception): + """Base class for other Interval exceptions""" + pass + + +class InvalidTimeFormatException(IntervalException): + """Raised when the time format is invalid""" + def __init__(self, time_str: str, message="Time must be in HH:MM format."): + self.time_str = time_str + self.message = message + super().__init__(self.message) + + +class InvalidIntervalException(IntervalException): + """Raised when the interval is invalid""" + def __init__(self, start_time: time, end_time: time, message: str = "Start time must be earlier than end time."): + self.start_time: str = start_time.strftime("%H:%M") + self.end_time: str = end_time.strftime("%H:%M") + self.message: str = message + super().__init__(self.message) + + +IMMUTABLE_DATE = datetime(year=2024, month=1, day=1) + +DEFAULT_INTERVAL = "9:00 - 17:00" + + +class Interval(BaseModel): + start_time_utc: datetime + end_time_utc: datetime + id: UUID = uuid4() + + @staticmethod + def validate_zone(tz: str): + try: + timezone(tz) + except UnknownTimeZoneError: + raise ValueError("You should pass valid zone name") + + @classmethod + def from_string(cls, interval_str: str, tz: str = "UTC", shift: int = 0): + cls.validate_zone(tz) + + start_str, end_str = interval_str.replace(" ", "").split('-') + start_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(start_str), ZoneInfo(tz)) + end_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(end_str), ZoneInfo(tz)) + + if start_time >= end_time: + raise InvalidIntervalException(start_time.time(), end_time.time()) + + hour_delta = timedelta(hours=shift) + + start_time_utc = cls.convert_to_utc(start_time) - hour_delta + end_time_utc = cls.convert_to_utc(end_time) - hour_delta + + return cls(start_time_utc=start_time_utc, end_time_utc=end_time_utc) + + @staticmethod + def convert_to_utc(local_time: datetime) -> datetime: + utc_dt = local_time.astimezone(ZoneInfo("UTC")) + return utc_dt + + @staticmethod + def parse_time(time_str: str) -> time: + try: + if ':' in time_str: + return datetime.strptime(time_str, "%H:%M").time() + else: + raise InvalidTimeFormatException(time_str) + except ValueError: + raise InvalidTimeFormatException(time_str) + + def convert_to_timezone(self, new_tz: str, shift: int) -> Tuple[datetime, datetime]: + self.validate_zone(new_tz) + + hour_delta = timedelta(hours=shift) + + new_start_dt = self.start_time_utc.astimezone(ZoneInfo(new_tz)) + hour_delta + new_end_dt = self.end_time_utc.astimezone(ZoneInfo(new_tz)) + hour_delta + return new_start_dt, new_end_dt + + def to_string(self, tz: str = "UTC", shift: int = 0): + self.validate_zone(tz) + + start_time, end_time = self.convert_to_timezone(new_tz=tz, shift=shift) + + return f"{start_time.strftime('%H:%M')} - {end_time.strftime('%H:%M')}" + + def __hash__(self): + return hash((self.start_time_utc, self.end_time_utc)) + + def __eq__(self, other): + if not isinstance(other, Interval): + return False + return (self.start_time_utc == other.start_time_utc and + self.end_time_utc == other.end_time_utc) + + def __str__(self): + return self.to_string() + + def __repr__(self): + return f"Interval({self.to_string()}, uid={self.id})" + + def overlaps_with(self, other): + start_a = self.start_time_utc.time() + end_a = self.end_time_utc.time() + start_b = other.start_time_utc.time() + end_b = other.end_time_utc.time() + + return max(start_a, start_b) < min(end_a, end_b) + + @staticmethod + def merge_intervals(intervals): + if not intervals: + return [] + + # Sort intervals by start time + sorted_intervals = sorted(intervals, key=lambda x: x.start_time_utc.time()) + + # Merge intervals + merged_intervals = list() + merged_intervals.append(sorted_intervals[0]) + for current in sorted_intervals[1:]: + last = merged_intervals[-1] + + if current.overlaps_with(last) or current.start_time_utc.time() <= last.end_time_utc.time(): + merged_intervals[-1] = Interval( + start_time_utc=min(last.start_time_utc, current.start_time_utc), + end_time_utc=max(last.end_time_utc, current.end_time_utc), + ) + else: + merged_intervals.append(current) + + return merged_intervals + + +def get_default_interval(tz: str, shift: int) -> Interval: + Interval.validate_zone(tz) + return Interval.from_string(DEFAULT_INTERVAL, tz, shift) + + +class DaySchedule(BaseModel): + name: str + included: bool = False + intervals: List[Interval] = [] + + def toggle_inclusion(self, tz: str, shift: int) -> None: + self.included = not self.included + if self.included and len(self.intervals) == 0: + self.add_interval(get_default_interval(tz, shift)) + + def add_interval(self, interval: Interval) -> None: + self.intervals.append(interval) + + def remove_interval(self, interval: Interval, tz: str, shift: int, ignore_inclusion=False) -> None: + self.intervals.remove(interval) + if not ignore_inclusion: + if len(self.intervals) == 0 and self.included: + self.toggle_inclusion(tz, shift) + + def get_interval(self, uid: UUID) -> Interval | None: + for interval in self.intervals: + if interval.id == uid: + return interval + return None + + def normalize_intervals(self): + # Delete duplicates + unique_intervals = set(self.intervals) + # Sort and merge intervals + if len(self.intervals) > 1: + self.intervals = Interval.merge_intervals(unique_intervals) + else: + self.intervals = list(unique_intervals) + return self + + def is_empty(self): + return not self.included and len(self.intervals) == 0 + + def __hash__(self): + return hash(self.name) + + def __eq__(self, other): + if not isinstance(other, DaySchedule): + return False + return (self.name == other.name and + self.included == other.included and + self.intervals == other.intervals) + + +def schedule_is_empty(schedule: Dict[str, DaySchedule]) -> bool: + return all(weekday.is_empty() for weekday in schedule.values()) + + +def is_working_time( + schedule: Dict[str, DaySchedule], + tz: str, + shift: int, + meeting_day: str, + meeting_time: time +) -> bool: + if not schedule[meeting_day].included: + return False + + for interval in schedule[meeting_day].intervals: + st, end = interval.convert_to_timezone(tz, shift) + st, end = st.time().replace(tzinfo=None), end.time().replace(tzinfo=None) + if st <= meeting_time <= end: + return True + + return False + + +def calculate_shift(old_tz: str, new_tz: str, old_shift: int, is_schedule_static: bool) -> int: + """Call this function every time you change the time zone. + + Args: + old_tz (str): Previous user/chat time zone. + new_tz (str): New user/chat time zone. + old_shift (int): Previous time zone shift. + is_schedule_static (bool): True if user wants to keep intervals same after changing time zone, False otherwise. + + Returns: + int: New time zone shift. + """ + Interval.validate_zone(old_tz) + Interval.validate_zone(new_tz) + + old_offset = datetime.now(ZoneInfo(old_tz)).utcoffset().total_seconds() // 3600 + new_offset = datetime.now(ZoneInfo(new_tz)).utcoffset().total_seconds() // 3600 + delta = int(old_offset - new_offset) + + if is_schedule_static: + return old_shift + delta + else: + return old_shift diff --git a/bot/keyboards.py b/bot/keyboards.py new file mode 100644 index 0000000..3089ce3 --- /dev/null +++ b/bot/keyboards.py @@ -0,0 +1,93 @@ +from typing import Dict +from uuid import UUID + +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardMarkup, InlineKeyboardButton + +from .callbacks import IntervalCallback, WeekdayCallback +from .intervals import Interval, DaySchedule +from .constants import days_array + +INCLUDED_1 = "โœ…" +INCLUDED_2 = "๐Ÿ—น" +INCLUDED_3 = "๐ŸŸฉ" +NOT_INCLUDED_1 = "โŽ" +NOT_INCLUDED_2 = "โ˜" +NOT_INCLUDED_3 = "๐ŸŸฅ" +ADD = "โž•" +REMOVE = "โœ–๏ธ" + + +def get_interval_keyboard(interval: Interval, weekday: str, tz: str, shift: int) -> InlineKeyboardBuilder: + builder = InlineKeyboardBuilder() + interval_str = interval.to_string(tz=tz, shift=shift) + interval_uid = interval.id + + builder.button( + text=interval_str, + callback_data=IntervalCallback(weekday=weekday, interval=interval_uid, action='edit') + ) + builder.button( + text=REMOVE, + callback_data=IntervalCallback(weekday=weekday, interval=interval_uid, action='remove') + ) + builder.button( + text=ADD, + callback_data=IntervalCallback(weekday=weekday, interval=interval_uid, action='add') + ) + builder.adjust(3) + + return builder + + +def get_weekday_keyboard(weekday: DaySchedule, tz: str, shift: int) -> InlineKeyboardBuilder: + builder = InlineKeyboardBuilder() + included_text = INCLUDED_3 if weekday.included else NOT_INCLUDED_3 + + day = InlineKeyboardButton(text=f"{weekday.name}", callback_data="#") + status = InlineKeyboardButton( + text=included_text, + callback_data=WeekdayCallback(weekday=weekday.name, action="toggle").pack() + ) + + builder.row(day, status) + + if weekday.included: + for interval in weekday.intervals: + interval_builder = get_interval_keyboard(interval, weekday.name, tz, shift) + builder.attach(interval_builder) + + return builder + + +def get_schedule_keyboard(week_schedule: Dict[str, DaySchedule], tz: str, shift: int) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + for weekday in days_array: + weekday_builder = get_weekday_keyboard(week_schedule[weekday], tz, shift) + builder.attach(weekday_builder) + + cancel = InlineKeyboardButton(text="Cancel", callback_data="cancel_schedule") + save = InlineKeyboardButton(text="Save", callback_data="save_schedule") + builder.row(cancel, save) + + return builder.as_markup() + + +def get_schedule_options() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + builder.button(text="Default", callback_data="default_schedule") + builder.button(text="Personal", callback_data="personal_schedule") + + return builder.adjust(2).as_markup() + + +def get_interval_edit_options(weekday: str, interval_uid: UUID) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + builder.button( + text="Enter again", + callback_data=IntervalCallback(weekday=weekday, interval=interval_uid, action='edit')) + builder.button(text="Cancel", callback_data="cancel_interval_edit") + + return builder.adjust(2).as_markup() diff --git a/bot/language.py b/bot/language.py index 09498eb..81ac71e 100644 --- a/bot/language.py +++ b/bot/language.py @@ -1,8 +1,5 @@ -from dataclasses import dataclass from enum import Enum -from pydantic import BaseModel - class Language(Enum): ru = "ru" diff --git a/bot/meeting.py b/bot/meeting.py index 09730df..57f4902 100644 --- a/bot/meeting.py +++ b/bot/meeting.py @@ -1,12 +1,14 @@ import logging from datetime import datetime from typing import Optional +from zoneinfo import ZoneInfo from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler -from .constants import day_of_week, jobstore +from .constants import day_of_week, jobstore, days_array from .custom_types import ChatId, SendMessage +from .intervals import is_working_time from .messages import make_daily_messages from .state import load_state, save_state, get_joined_users @@ -14,10 +16,24 @@ async def send_meeting_messages(chat_id: ChatId, topic_id: Optional[int], send_message: SendMessage): chat_state = await load_state(chat_id=chat_id, topic_id=topic_id) current_day = datetime.now().weekday() + current_day = days_array[current_day] + current_time = datetime.now().astimezone(ZoneInfo(chat_state.time_zone)).time().replace( + second=0, + microsecond=0, + tzinfo=None + ) + await send_message(chat_id=chat_id, message=_("Meeting time!"), message_thread_id=topic_id) - + joined_users = await get_joined_users(chat_state) - today_workers = [user for user in joined_users if current_day in user.meeting_days] + today_workers = [user for user in joined_users if is_working_time( + schedule=user.schedule, + tz=user.time_zone, + shift=user.time_zone_shift, + meeting_day=current_day, + meeting_time=current_time + )] + if not today_workers: await send_message(chat_id=chat_id, message=_("Nobody has joined the meeting!"), message_thread_id=topic_id) else: diff --git a/bot/messages.py b/bot/messages.py index 8c10d42..5db6864 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -1,14 +1,15 @@ -from dataclasses import dataclass -from datetime import datetime from textwrap import dedent -from typing import List +from typing import List, Tuple from aiogram import html +from aiogram.utils import markdown as fmt from aiogram.utils.i18n import gettext as _ from .commands import bot_command_descriptions, bot_command_names -from .constants import day_of_week_pretty -from .language import Language +from .intervals import Interval, InvalidTimeFormatException, InvalidIntervalException + + +DEFAULT_EDITING_INTERVAL = "19:00 - 22:30" def bot_intro(): @@ -37,8 +38,8 @@ def make_help_message() -> str: /{command_names.set_meetings_time} - {command_descriptions.set_meetings_time} {html.bold(_("Personal settings commands"))} - /{command_names.set_personal_meetings_days} - {command_descriptions.set_personal_meetings_days} /{command_names.set_reminder_period} - {command_descriptions.set_reminder_period} + /{command_names.set_working_hours} - {command_descriptions.set_working_hours} /{command_names.join} - {command_descriptions.join} /{command_names.skip} - {command_descriptions.skip} @@ -56,3 +57,40 @@ def make_daily_messages(usernames: str) -> List[str]: usernames=usernames ), ] + + +def make_interval_validation_message(interval_str: str, tz: str, shift: int) -> Tuple[bool, str]: + try: + interval = Interval.from_string(interval_str=interval_str, tz=tz, shift=shift) + msg = _("Successfully parsed interval: {interval}").format(interval=interval) + return True, msg + except InvalidTimeFormatException as e: + msg = _("Invalid time format for '{time}'. {msg}\n").format(time=e.time_str, msg=e.message) + return False, msg + except InvalidIntervalException as e: + msg = _("{msg} (start: {start}, end: {end}).\n").format( + msg=e.message, + start=e.start_time, + end=e.end_time + ) + return False, msg + except Exception as e: + msg = _("An unexpected error occurred. {error}").format(error=str(e)) + return False, msg + + +def make_interval_editing_instruction() -> str: + instruction_text = "Send me the new interval in the hh:mm - hh:mm format." + example_text = "Example: " + note = "Notes:\nThe example provides an interval copyable by clicking or tapping it." + + text = fmt.text(instruction_text, "\n", example_text, fmt.hcode(DEFAULT_EDITING_INTERVAL), "\n", note, sep="") + return text + + +def make_interval_editing_error(error_msg: str) -> str: + again_text = "Press 'Enter again' to enter the interval again." + cancel_text = "Press 'Cancel' to cancel editing this interval." + + text = fmt.text(error_msg, "\n", again_text, "\n", cancel_text, sep="") + return text diff --git a/bot/middlewares.py b/bot/middlewares.py new file mode 100644 index 0000000..6f71dcb --- /dev/null +++ b/bot/middlewares.py @@ -0,0 +1,21 @@ +from typing import Any, Callable, Dict, Awaitable + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Update + + +class GroupCommandFilterMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + if isinstance(event, Update) and event.message: + chat = event.message.chat + text = event.message.text + + if chat.type == "group" and not text.startswith("/start"): + return + + return await handler(event, data) diff --git a/bot/reminder.py b/bot/reminder.py index f3bb40d..31bd0ff 100644 --- a/bot/reminder.py +++ b/bot/reminder.py @@ -9,10 +9,9 @@ from apscheduler.triggers.interval import IntervalTrigger from .messages import make_daily_messages -from .constants import day_of_week, jobstore +from .constants import jobstore from .custom_types import ChatId, SendMessage from .state import load_state, load_user_pm, get_user, ChatState -from textwrap import dedent async def send_reminder_messages( @@ -45,7 +44,7 @@ async def send_reminder_messages( chat_type=chat_type ) if msg_link: # supergroup - reminder_message += "\n" + msg_link + reminder_message += "\n" + messages[reply] + msg_link else: # group chanel or private reminder_message = messages[reply] @@ -62,7 +61,7 @@ async def send_reminder_messages( reply_to_message_id=message_id ) - if chat_type == "supergroup" and len(chat_state.meeting_msg_ids) == 3: + if chat_type == "supergroup" and len(chat_state.meeting_msg_ids) == 3 and len(have_to_reply) > 0: await send_message( chat_id=user_chat_id, message=reminder_message @@ -73,14 +72,10 @@ async def send_reminder_messages( bot_username = bot_info.username if chat_type != "private": - banned_msg = dedent( - _( - """ - {username}, please unblock {bot_username} (it's me) in our private chat - so that I can send you reminders about missed daily meeting questions. - """ - ).format(username=username, bot_username=bot_username) - ) + banned_msg = _( + "@{username}, please unblock @{bot_username} (it's me) in our private chat " + "so that I can send you reminders about missed daily meeting questions." + ).format(username=username, bot_username=bot_username) await send_message( chat_id=meeting_chat_id, diff --git a/bot/state.py b/bot/state.py index 532d38b..7bb4cc7 100644 --- a/bot/state.py +++ b/bot/state.py @@ -1,6 +1,6 @@ +from uuid import UUID from datetime import datetime from typing import Annotated, Optional, Dict, List -from zoneinfo import ZoneInfo import pymongo from beanie import Document, Indexed @@ -8,15 +8,30 @@ from .chat import ChatId from .language import Language +from .intervals import DaySchedule +from .constants import default_time_zone, default_schedule class ChatUser(BaseModel): - username: str = "" + username: str = "" is_joined: bool = False - meeting_days: set[int] = set(range(0, 5)) # default value - [0 - 4] = Monday - Friday - reminder_period: Optional[int] = None + time_zone: str = default_time_zone + non_replied_daily_msgs: set[int] = set(range(0, 3)) + reminder_period: Optional[int] = None + + schedule: Dict[str, DaySchedule] = default_schedule + temp_schedule: Optional[Dict[str, DaySchedule]] = None + time_zone_shift: int = 0 # 0 for dynamic schedule; {UTC_offset_old - UTC_offset_new} for static schedule; + + # TODO: relocate these fields to cache (Redis for example) + schedule_mode: Optional[str] = None + schedule_msg: Optional[int] = None + to_delete_msg_ids: set[int] = set() + to_edit_weekday: Optional[str] = None + to_edit_interval: Optional[UUID] = None + def __hash__(self): return hash(self.username) @@ -42,12 +57,18 @@ async def create_user(username: str) -> ChatUser: class ChatState(Document): language: Language = Language.default - meeting_time: Optional[datetime] = None - meeting_msg_ids: list[int] = [] + time_zone: str = default_time_zone topic_id: Optional[int] = None chat_id: Annotated[ChatId, Indexed(index_type=pymongo.ASCENDING)] users: Dict[str, ChatUser] = dict() + meeting_time: Optional[datetime] = None + meeting_msg_ids: list[int] = [] + + schedule: Dict[str, DaySchedule] = default_schedule + temp_schedule: Optional[Dict[str, DaySchedule]] = None + time_zone_shift: int = 0 # 0 for dynamic schedule; {UTC_offset_old - UTC_offset_new} for static schedule; + async def get_user(chat_state: ChatState, username: str) -> ChatUser: """Load a user from the ChatState by username or create a new one if not found. @@ -119,13 +140,14 @@ async def save_state(chat_state: ChatState) -> None: Args: chat_state (ChatState): The chat state instance to save. """ - + await chat_state.save() class UserPM(Document): username: str chat_id: Annotated[ChatId, Indexed(index_type=pymongo.ASCENDING)] + personal_time_zone: str = default_time_zone async def create_user_pm(username: str, chat_id: ChatId) -> UserPM: @@ -137,4 +159,4 @@ async def load_user_pm(username: str) -> Optional[UserPM]: async def save_user_pm(user_pm: UserPM) -> None: - await user_pm.save() \ No newline at end of file + await user_pm.save() diff --git a/bot/work_time.py b/bot/work_time.py new file mode 100644 index 0000000..c73cec1 --- /dev/null +++ b/bot/work_time.py @@ -0,0 +1,290 @@ +import re + +import asyncio +from aiogram import Bot, Router, F +from aiogram.filters.command import Command +from aiogram.types import Message, CallbackQuery +from aiogram.fsm.context import FSMContext +from aiogram.utils import markdown as fmt +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from .commands import bot_command_names +from .custom_types import SendMessage +from .messages import make_interval_validation_message, make_interval_editing_instruction, make_interval_editing_error +from .callbacks import IntervalCallback, WeekdayCallback +from .fsm_states import IntervalEditingState +from .intervals import Interval, schedule_is_empty, get_default_interval +from .filters import HasChatState, HasMessageUserUsername +from .keyboards import get_schedule_keyboard, get_schedule_options, get_interval_edit_options +from .state import ChatState, ChatUser, save_state, get_user + + +INTERVAL_PATTERN = r"^\d{1,2}:\d{2}\s*-\s*\d{1,2}:\d{2}$" + + +def get_schedule_by_mode(chat_state: ChatState, user: ChatUser): + mode = user.schedule_mode + if mode == "personal": + week_schedule = user.temp_schedule + tz = user.time_zone + shift = user.time_zone_shift + else: + week_schedule = chat_state.temp_schedule + tz = chat_state.time_zone + shift = chat_state.time_zone_shift + + return week_schedule, tz, shift + + +def handle_working_hours( + scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot +): + @router.message(Command(bot_command_names.set_working_hours)) + async def show_schedule_options(message: Message): + + layout = get_schedule_options() + + st = "Press 'Default' to set working hours that will be used as default personal working hours by all people." + end = "Press 'Personal' to set personal working hours." + text = fmt.text(st, "\n", end, sep="") + + await message.answer(text=text, reply_markup=layout) + + @router.callback_query(F.data.regexp("(default|personal)_schedule"), HasMessageUserUsername(), HasChatState()) + async def show_schedule(cb: CallbackQuery, username: str, chat_state: ChatState): + + await cb.answer() + + mode = cb.data.split("_")[0] + + user = await get_user(chat_state, username) + + if mode == "personal": + user.temp_schedule = user.schedule + week_schedule = user.temp_schedule + tz = user.time_zone + shift = user.time_zone_shift + else: + chat_state.temp_schedule = chat_state.schedule + week_schedule = chat_state.temp_schedule + tz = chat_state.time_zone + shift = chat_state.time_zone_shift + + layout = get_schedule_keyboard(week_schedule, tz, shift) + schedule_msg = await cb.message.answer(f"@{username}, here is your {mode} schedule", reply_markup=layout) + + # Cache + user.schedule_mode = mode + user.schedule_msg = schedule_msg.message_id + + await save_state(chat_state) + + @router.callback_query(IntervalCallback.filter(F.action == "edit"), HasMessageUserUsername(), HasChatState()) + async def show_interval_editing_instruction( + cb: CallbackQuery, + state: FSMContext, + callback_data: IntervalCallback, + username: str, + chat_state: ChatState + ): + + weekday = callback_data.weekday + interval_uid = callback_data.interval + + text = make_interval_editing_instruction() + + await state.set_state(IntervalEditingState.EnterNewInterval) + + await cb.answer() + edit_message = await cb.message.answer(text=text) + + # Cache + user = await get_user(chat_state, username) + user.to_edit_weekday = weekday + user.to_edit_interval = interval_uid + user.to_delete_msg_ids.add(edit_message.message_id) + await save_state(chat_state) + + @router.message( + IntervalEditingState.EnterNewInterval, + HasMessageUserUsername(), + HasChatState() + ) + async def handle_interval_editing(message: Message, state: FSMContext, username: str, chat_state: ChatState): + + parse_pattern = re.compile(INTERVAL_PATTERN) + parse_match = re.fullmatch(parse_pattern, message.text.strip()) + + # Cache + user = await get_user(chat_state, username) + user.to_delete_msg_ids.add(message.message_id) + + # Text entered by user is not interval + error_msg_text = "" + if not parse_match: + error_msg_text = f"The interval {message.text.strip()} isn't in the hh:mm - hh:mm format.\n" + + # User entered one "valid" interval + else: + week_schedule, tz, shift = get_schedule_by_mode(chat_state=chat_state, user=user) + + weekday = user.to_edit_weekday + old_interval_uid = user.to_edit_interval + + new_interval = parse_match[0] + is_valid, error_msg_text = make_interval_validation_message(interval_str=new_interval, tz=tz, shift=shift) + + # Interval is valid + if is_valid: + old_interval_obj = week_schedule[weekday].get_interval(uid=old_interval_uid) + new_interval_obj = Interval.from_string(interval_str=new_interval, tz=tz, shift=shift) + week_schedule[weekday].remove_interval(old_interval_obj, tz, shift, ignore_inclusion=True) + week_schedule[weekday].add_interval(new_interval_obj) + + layout = get_schedule_keyboard(week_schedule, tz, shift) + await bot.edit_message_reply_markup( + chat_id=chat_state.chat_id, + message_id=user.schedule_msg, + reply_markup=layout + ) + + success_msg = await message.answer(f"OK, the interval was set to {new_interval}.") + await asyncio.sleep(1) + await success_msg.delete() + for msg_id in user.to_delete_msg_ids: + await bot.delete_message(chat_id=chat_state.chat_id, message_id=msg_id) + + user.to_delete_msg_ids = set() + + await state.clear() + await save_state(chat_state) + return + + error_msg_text = make_interval_editing_error(error_msg=error_msg_text) + layout = get_interval_edit_options(weekday=user.to_edit_weekday, interval_uid=user.to_edit_interval) + error_msg = await message.answer(text=error_msg_text, reply_markup=layout) + + user.to_delete_msg_ids.add(error_msg.message_id) + + await save_state(chat_state) + + @router.callback_query(F.data == "cancel_interval_edit", HasMessageUserUsername(), HasChatState()) + async def cancel_interval_editing(cb: CallbackQuery, state: FSMContext, username: str, chat_state: ChatState): + + await cb.answer() + user = await get_user(chat_state, username) + + user.to_edit_weekday = None + user.to_edit_interval = None + + for msg_id in user.to_delete_msg_ids: + await bot.delete_message(chat_id=chat_state.chat_id, message_id=msg_id) + user.to_delete_msg_ids = set() + await state.clear() + await save_state(chat_state) + + @router.callback_query(IntervalCallback.filter(F.action == 'add'), HasMessageUserUsername(), HasChatState()) + async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback, username: str, chat_state: ChatState): + + weekday = callback_data.weekday + + user = await get_user(chat_state, username) + + week_schedule, tz, shift = get_schedule_by_mode(chat_state=chat_state, user=user) + + interval = get_default_interval(tz=tz, shift=shift) + week_schedule[weekday].add_interval(interval) + + layout = get_schedule_keyboard(week_schedule, tz, shift) + await cb.message.edit_reply_markup(reply_markup=layout) + await save_state(chat_state) + + @router.callback_query(IntervalCallback.filter(F.action == 'remove'), HasMessageUserUsername(), HasChatState()) + async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback, username: str, chat_state: ChatState): + + weekday = callback_data.weekday + interval_uid = callback_data.interval + + user = await get_user(chat_state, username) + + week_schedule, tz, shift = get_schedule_by_mode(chat_state=chat_state, user=user) + + interval = week_schedule[weekday].get_interval(interval_uid) + week_schedule[weekday].remove_interval(interval, tz, shift) + + layout = get_schedule_keyboard(week_schedule, tz, shift) + await cb.message.edit_reply_markup(reply_markup=layout) + await save_state(chat_state) + + @router.callback_query(WeekdayCallback.filter(F.action == 'toggle'), HasMessageUserUsername(), HasChatState()) + async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback, username: str, chat_state: ChatState): + + weekday = callback_data.weekday + + user = await get_user(chat_state, username) + + week_schedule, tz, shift = get_schedule_by_mode(chat_state=chat_state, user=user) + + week_schedule[weekday].toggle_inclusion(tz, shift) + + layout = get_schedule_keyboard(week_schedule, tz, shift) + await cb.message.edit_reply_markup(reply_markup=layout) + await save_state(chat_state) + + @router.callback_query(F.data == "cancel_schedule", HasMessageUserUsername(), HasChatState()) + async def cancel_changes(cb: CallbackQuery, state: FSMContext, username: str, chat_state: ChatState): + + await cb.answer() + + user = await get_user(chat_state, username) + mode = user.schedule_mode + if mode == "personal": + user.temp_schedule = None + else: + chat_state.temp_schedule = None + + user.schedule_mode = None + user.schedule_msg = None + user.to_edit_weekday = None + user.to_edit_interval = None + + if len(user.to_delete_msg_ids) > 0: + for msg_id in user.to_delete_msg_ids: + await bot.delete_message(chat_id=chat_state.chat_id, message_id=msg_id) + user.to_delete_msg_ids = set() + + await state.clear() + await cb.message.answer(f"The {mode} schedule was not updated.") + await save_state(chat_state) + + @router.callback_query(F.data == "save_schedule", HasMessageUserUsername(), HasChatState()) + async def save_changes(cb: CallbackQuery, username: str, chat_state: ChatState): + + await cb.answer() + + user = await get_user(chat_state, username) + mode = user.schedule_mode + if mode == "personal": + norm_schedule = {item[0]: item[1].normalize_intervals() for item in user.temp_schedule.items()} + user.temp_schedule = norm_schedule + user.schedule = user.temp_schedule + user.temp_schedule = None + else: + norm_schedule = {item[0]: item[1].normalize_intervals() for item in chat_state.temp_schedule.items()} + chat_state.temp_schedule = norm_schedule + chat_state.schedule = chat_state.temp_schedule + chat_state.temp_schedule = None + if schedule_is_empty(user.schedule): + user.schedule = chat_state.schedule + + user.schedule_mode = None + user.schedule_msg = None + user.to_edit_weekday = None + user.to_edit_interval = None + + await cb.message.answer(f"The {mode} schedule was updated.") + await save_state(chat_state) + + @router.callback_query(F.data == "#") + async def handle_placeholders(cb: CallbackQuery): + await cb.answer() diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..ba12c0b --- /dev/null +++ b/cliff.toml @@ -0,0 +1,89 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[changelog] +# template for the changelog footer +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# template for the changelog footer +footer = """ + +""" +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [ + # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL +] + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Replace issue numbers + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit with https://github.com/crate-ci/typos + # If the spelling is incorrect, it will be automatically fixed. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "๐Ÿš€ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^refactor", group = "๐Ÿšœ Refactor" }, + { message = "^style", group = "๐ŸŽจ Styling" }, + { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = "โš™๏ธ Miscellaneous Tasks" }, + { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, + { message = "^revert", group = "โ—€๏ธ Revert" }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# regex for matching git tags +# tag_pattern = "v[0-9].*" +# regex for skipping tags +# skip_tags = "" +# regex for ignoring tags +# ignore_tags = "" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +# limit the number of commits included in the changelog. +# limit_commits = 42 diff --git a/poetry.lock b/poetry.lock index b8cef8b..65f7757 100644 --- a/poetry.lock +++ b/poetry.lock @@ -336,6 +336,70 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.5.4" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"}, + {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"}, + {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"}, + {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"}, + {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"}, + {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"}, + {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"}, + {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"}, + {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"}, + {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"}, + {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"}, + {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"}, + {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"}, + {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"}, + {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"}, + {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"}, + {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"}, + {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"}, + {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"}, + {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"}, + {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"}, + {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"}, + {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"}, + {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"}, + {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"}, + {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"}, + {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"}, + {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"}, + {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"}, + {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"}, + {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"}, + {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"}, + {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"}, + {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"}, + {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"}, + {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"}, + {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"}, + {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"}, + {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"}, + {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"}, + {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"}, + {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"}, + {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"}, + {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"}, + {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"}, + {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"}, + {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"}, + {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"}, + {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"}, + {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"}, + {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"}, + {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"}, +] + +[package.extras] +toml = ["tomli"] + [[package]] name = "dataclass-wizard" version = "0.22.3" @@ -484,6 +548,17 @@ files = [ {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + [[package]] name = "isort" version = "5.13.2" @@ -537,6 +612,35 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mongomock" +version = "4.1.2" +description = "Fake pymongo stub for testing simple MongoDB-dependent code" +optional = false +python-versions = "*" +files = [ + {file = "mongomock-4.1.2-py2.py3-none-any.whl", hash = "sha256:08a24938a05c80c69b6b8b19a09888d38d8c6e7328547f94d46cadb7f47209f2"}, + {file = "mongomock-4.1.2.tar.gz", hash = "sha256:f06cd62afb8ae3ef63ba31349abd220a657ef0dd4f0243a29587c5213f931b7d"}, +] + +[package.dependencies] +packaging = "*" +sentinels = "*" + +[[package]] +name = "mongomock-motor" +version = "0.0.30" +description = "Library for mocking AsyncIOMotorClient built on top of mongomock." +optional = false +python-versions = ">=3.6" +files = [ + {file = "mongomock_motor-0.0.30-py3-none-any.whl", hash = "sha256:5eb41488ce5825ebf1a1ddc90c6ff2c870b2d8506bd1096790408f53068f0ca6"}, + {file = "mongomock_motor-0.0.30.tar.gz", hash = "sha256:7977413755f70c7ca306e407f5e0d1e49dfa428ce6f2551b097f4db59f8c285b"}, +] + +[package.dependencies] +mongomock = ">=3.23.0,<5.0.0" + [[package]] name = "motor" version = "3.4.0" @@ -755,6 +859,21 @@ docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx- test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] type = ["mypy (>=1.8)"] +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + [[package]] name = "pydantic" version = "2.7.4" @@ -992,6 +1111,44 @@ snappy = ["python-snappy"] test = ["pytest (>=7)"] zstd = ["zstandard"] +[[package]] +name = "pytest" +version = "8.2.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, + {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2.0" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.7" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, + {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "python-dotenv" version = "1.0.1" @@ -1017,6 +1174,43 @@ files = [ {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, ] +[[package]] +name = "ruff" +version = "0.5.1" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.5.1-py3-none-linux_armv6l.whl", hash = "sha256:6ecf968fcf94d942d42b700af18ede94b07521bd188aaf2cd7bc898dd8cb63b6"}, + {file = "ruff-0.5.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:204fb0a472f00f2e6280a7c8c7c066e11e20e23a37557d63045bf27a616ba61c"}, + {file = "ruff-0.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d235968460e8758d1e1297e1de59a38d94102f60cafb4d5382033c324404ee9d"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38beace10b8d5f9b6bdc91619310af6d63dd2019f3fb2d17a2da26360d7962fa"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e478d2f09cf06add143cf8c4540ef77b6599191e0c50ed976582f06e588c994"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0368d765eec8247b8550251c49ebb20554cc4e812f383ff9f5bf0d5d94190b0"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3a9a9a1b582e37669b0138b7c1d9d60b9edac880b80eb2baba6d0e566bdeca4d"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdd9f723e16003623423affabcc0a807a66552ee6a29f90eddad87a40c750b78"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9fd62c1e99539da05fcdc1e90d20f74aec1b7a1613463ed77870057cd6bd96"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e216fc75a80ea1fbd96af94a6233d90190d5b65cc3d5dfacf2bd48c3e067d3e1"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c4c2112e9883a40967827d5c24803525145e7dab315497fae149764979ac7929"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dfaf11c8a116394da3b65cd4b36de30d8552fa45b8119b9ef5ca6638ab964fa3"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d7ceb9b2fe700ee09a0c6b192c5ef03c56eb82a0514218d8ff700f6ade004108"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bac6288e82f6296f82ed5285f597713acb2a6ae26618ffc6b429c597b392535c"}, + {file = "ruff-0.5.1-py3-none-win32.whl", hash = "sha256:5c441d9c24ec09e1cb190a04535c5379b36b73c4bc20aa180c54812c27d1cca4"}, + {file = "ruff-0.5.1-py3-none-win_amd64.whl", hash = "sha256:b1789bf2cd3d1b5a7d38397cac1398ddf3ad7f73f4de01b1e913e2abc7dfc51d"}, + {file = "ruff-0.5.1-py3-none-win_arm64.whl", hash = "sha256:2875b7596a740cbbd492f32d24be73e545a4ce0a3daf51e4f4e609962bfd3cd2"}, + {file = "ruff-0.5.1.tar.gz", hash = "sha256:3164488aebd89b1745b47fd00604fb4358d774465f20d1fcd907f9c0fc1b0655"}, +] + +[[package]] +name = "sentinels" +version = "1.0.0" +description = "Various objects to denote special meanings in python" +optional = false +python-versions = "*" +files = [ + {file = "sentinels-1.0.0.tar.gz", hash = "sha256:7be0704d7fe1925e397e92d18669ace2f619c92b5d4eb21a89f31e026f9ff4b1"}, +] + [[package]] name = "six" version = "1.16.0" @@ -1195,4 +1389,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "e8ae960678a3a927a13f4f5f3de26b609ae82fbe989f0232cee2f9163eac69bf" +content-hash = "7701f5340e25b3c9369ba6745b40ab431635d5cbb3074c8cb050458bebf24f21" diff --git a/pyproject.toml b/pyproject.toml index 05fce26..98a6a8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,11 @@ black = "^24.4.2" mypy = "^1.10.0" pylint = "^3.1.0" babel = "^2.15.0" +pytest = "^8.2.2" +pytest-asyncio = "^0.23.7" +mongomock-motor = "^0.0.30" +coverage = "^7.5.4" +ruff = "^0.5.1" [tool.poetry.scripts] bot = "bot.main:main" diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100755 index 0000000..a726ca2 --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,3 @@ +#! /bin/bash +echo "Running the tests..." +poetry run pytest diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dbd2091 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +import pytest_asyncio +from mongomock_motor import AsyncMongoMockClient +from beanie import init_beanie +from aiogram import Dispatcher + +from bot.state import ChatState, UserPM, create_state, get_user, save_state + +from tests.utils import TEST_CHAT, TEST_USER, TEST_USER_2 + + +# @pytest.fixture() +# def bot(): +# return MockedBot() + + +@pytest_asyncio.fixture() +async def dispatcher(): + dp = Dispatcher() + await dp.emit_startup() + try: + yield dp + finally: + await dp.emit_shutdown() + + +@pytest_asyncio.fixture(autouse=True) +async def mock_db(): + """ + Automatic fixture. Create new mongodb instance for each test. + """ + client = AsyncMongoMockClient() + await init_beanie(document_models=[ChatState, UserPM], database=client.get_database(name="db")) + + +@pytest_asyncio.fixture() +async def chat_state_2_users(mock_db) -> ChatState: + """ + Create new ChatState with 2 test users. + Does not register users (they do not /start bot in PM). + Returns: + ChatState: The chat state instance created. + """ + chat_state = await create_state(TEST_CHAT.id, topic_id=1) + await get_user(chat_state, TEST_USER.username) + await get_user(chat_state, TEST_USER_2.username) + await save_state(chat_state) + yield chat_state + +# @pytest.fixture(scope="session") +# def event_loop(): +# return asyncio.get_event_loop() diff --git a/tests/mocked_bot.py b/tests/mocked_bot.py new file mode 100644 index 0000000..3547aa8 --- /dev/null +++ b/tests/mocked_bot.py @@ -0,0 +1,99 @@ +from collections import deque +from typing import TYPE_CHECKING, Any, AsyncGenerator, Deque, Dict, Optional, Type + +from aiogram import Bot +from aiogram.client.session.base import BaseSession +from aiogram.methods import TelegramMethod +from aiogram.methods.base import Response, TelegramType +from aiogram.types import UNSET_PARSE_MODE, ResponseParameters, User + + +class MockedSession(BaseSession): + def __init__(self): + super(MockedSession, self).__init__() + self.responses: Deque[Response[TelegramType]] = deque() + self.requests: Deque[TelegramMethod[TelegramType]] = deque() + self.closed = True + + def add_result(self, response: Response[TelegramType]) -> Response[TelegramType]: + self.responses.append(response) + return response + + def get_request(self) -> TelegramMethod[TelegramType]: + return self.requests.pop() + + async def close(self): + self.closed = True + + async def make_request( + self, + bot: Bot, + method: TelegramMethod[TelegramType], + timeout: Optional[int] = UNSET_PARSE_MODE, + ) -> TelegramType: + self.closed = False + self.requests.append(method) + response: Response[TelegramType] = self.responses.pop() + self.check_response( + bot=bot, + method=method, + status_code=response.error_code, + content=response.model_dump_json(), + ) + return response.result # type: ignore + + async def stream_content( + self, + url: str, + headers: Optional[Dict[str, Any]] = None, + timeout: int = 30, + chunk_size: int = 65536, + raise_for_status: bool = True, + ) -> AsyncGenerator[bytes, None]: # pragma: no cover + yield b"" + + +class MockedBot(Bot): + if TYPE_CHECKING: + session: MockedSession + + def __init__(self, **kwargs): + super(MockedBot, self).__init__( + kwargs.pop("token", "42:TEST"), session=MockedSession(), **kwargs + ) + self._me = User( + id=self.id, + is_bot=True, + first_name="FirstName", + last_name="LastName", + username="tbot", + language_code="uk-UA", + ) + + def add_result_for( + self, + method: Type[TelegramMethod[TelegramType]], + ok: bool, + result: TelegramType = None, + description: Optional[str] = None, + error_code: int = 200, + migrate_to_chat_id: Optional[int] = None, + retry_after: Optional[int] = None, + ) -> Response[TelegramType]: + response = Response[method.__returning__]( # type: ignore + ok=ok, + result=result, + description=description, + error_code=error_code, + parameters=ResponseParameters( + migrate_to_chat_id=migrate_to_chat_id, + retry_after=retry_after, + ), + ) + self.session.add_result(response) + return response + + def get_request(self) -> TelegramMethod[TelegramType]: + return self.session.get_request() + + diff --git a/tests/test_handlers/__init__.py b/tests/test_handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_non_handlers/__init__.py b/tests/test_non_handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_non_handlers/test_get_message_link.py b/tests/test_non_handlers/test_get_message_link.py new file mode 100644 index 0000000..2e482fe --- /dev/null +++ b/tests/test_non_handlers/test_get_message_link.py @@ -0,0 +1,38 @@ +from bot.reminder import get_message_link +from tests.utils import TEST_CHAT + + +def test_get_message_link(): + """ + Tests the accuracy of the generation of tg links. + """ + msg_id = 1 + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=None, + chat_type="supergroup") == "https://t.me/c/%s/%s" % (str(TEST_CHAT.id)[4:], msg_id) + + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=1, + chat_type="supergroup") == "https://t.me/c/%s/%s/%s" % (str(TEST_CHAT.id)[4:], msg_id, 1) + + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=None, + chat_type="private") is None + + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=None, + chat_type="group") is None + + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=None, + chat_type="channel") is None + + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=1, + chat_type="channel") is None diff --git a/tests/test_non_handlers/test_intervals.py b/tests/test_non_handlers/test_intervals.py new file mode 100644 index 0000000..1721604 --- /dev/null +++ b/tests/test_non_handlers/test_intervals.py @@ -0,0 +1,128 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +# import pytest + +from bot.intervals import Interval, IMMUTABLE_DATE + +# TODO: change commented tests according to the current intervals.py +# def test_zone_must_be_valid(): +# """ +# Check correctness of the validation of the time zone. +# """ +# a = Interval.zone_must_be_valid("Us/eastern") +# assert a == "Us/eastern" +# +# a = Interval.zone_must_be_valid("Europe/Moscow") +# assert a == "Europe/Moscow" +# +# with pytest.raises(ValueError): +# b = Interval.zone_must_be_valid("somezone") + + +# def test_from_string(): +# """ +# Checks correctness of the creation of an interval. +# """ +# tz_1 = "UTC" +# tz_2 = "Europe/Moscow" +# time_start_1 = datetime.strptime("1:00", "%H:%M").time() +# time_end_1 = datetime.strptime("11:20", "%H:%M").time() +# +# datetime_start_1 = datetime.combine(IMMUTABLE_DATE, time_start_1, ZoneInfo(tz_1)) +# datetime_end_1 = datetime.combine(IMMUTABLE_DATE, time_end_1, ZoneInfo(tz_1)) +# +# datetime_start_2 = datetime.combine(IMMUTABLE_DATE, time_start_1, ZoneInfo(tz_2)) +# datetime_end_2 = datetime.combine(IMMUTABLE_DATE, time_end_1, ZoneInfo(tz_2)) +# +# a = Interval.from_string("1:00 - 11:20", tz_1) +# assert a.start_time_utc == datetime_start_1 +# assert a.end_time_utc == datetime_end_1 +# assert a.tz == tz_1 +# +# # Start time should be less than end one +# with pytest.raises(InvalidIntervalException): +# a = Interval.from_string("11:20 - 1:00", tz_1) +# +# # Skip time zone +# a = Interval.from_string("1:00 - 11:20") +# assert a.start_time_utc == datetime_start_1 +# assert a.end_time_utc == datetime_end_1 +# assert a.tz == tz_1 +# +# # Other time zone +# a = Interval.from_string("1:00 - 11:20", tz_2) +# assert a.start_time_utc == datetime_start_2 +# assert a.end_time_utc == datetime_end_2 +# assert a.tz == tz_2 +# +# # Other time format +# a = Interval.from_string("1.00 - 11.20", tz_2) +# assert a.start_time_utc == datetime_start_2 +# assert a.end_time_utc == datetime_end_2 +# assert a.tz == tz_2 +# + +def test_convert_to_utc(): + tz_1 = "Europe/Moscow" + time_start_1 = datetime.strptime("1:00", "%H:%M").time() + datetime_1 = datetime.combine(IMMUTABLE_DATE, time_start_1, ZoneInfo(tz_1)) + + assert datetime_1.astimezone(ZoneInfo("UTC")) == Interval.convert_to_utc(datetime_1) + + +# def test_parse_time(): +# time_1 = "01:00" +# time_2 = "23.12" +# wrong_time = "24:99" +# +# assert datetime.strptime(time_1, "%H:%M").time() == Interval.parse_time(time_1) +# assert datetime.strptime(time_2, "%H.%M").time() == Interval.parse_time(time_2) +# +# with pytest.raises(InvalidTimeFormatException): +# Interval.parse_time(wrong_time) + + +def test_overlaps_with(): + interval_1 = Interval.from_string("1:00 - 4:00", tz="UTC") + # Should overlap with 1 + interval_2 = Interval.from_string("4:00 - 5:00", tz="UTC") + interval_3 = Interval.from_string("3:00 - 5:00", tz="UTC") + interval_4 = Interval.from_string("2:00 - 3:00", tz="UTC") + + # Should not overlap with 1 + interval_5 = Interval.from_string("6:00 - 7:00", tz="UTC") + + test_func = Interval.overlaps_with + assert test_func(interval_1, interval_1) is True + assert test_func(interval_1, interval_2) is False + assert test_func(interval_1, interval_3) is True + assert test_func(interval_1, interval_4) is True + assert test_func(interval_1, interval_5) is False + + +# def test_merge_intervals(): +# # TODO several same intervals +# interval_1 = Interval.from_string("1:00 - 4:00", tz="UTC") +# interval_2 = Interval.from_string("1:20 - 4:00", tz="UTC") +# interval_3 = Interval.from_string("2:00 - 3:00", tz="UTC") +# interval_4 = Interval.from_string("4:00 - 5:00", tz="UTC") +# interval_5 = Interval.from_string("7:00 - 8:00", tz="UTC") +# +# interval_6 = Interval.from_string("1:00 - 5:00", tz="UTC") +# +# interval_7 = Interval.from_string("2:00 - 3:00", tz="Europe/Moscow") +# +# arr = [interval_1, interval_2, interval_3, interval_4, interval_5] +# merged = [interval_6, interval_5] +# +# assert Interval.merge_intervals(arr) == merged +# +# # Different time zones +# with pytest.raises(ValueError): +# Interval.merge_intervals(arr + [interval_7]) +# +# # Empty +# # TODO output should be empty +# with pytest.raises(ValueError): +# Interval.merge_intervals([]) diff --git a/tests/test_non_handlers/test_make_job_id.py b/tests/test_non_handlers/test_make_job_id.py new file mode 100644 index 0000000..725fc92 --- /dev/null +++ b/tests/test_non_handlers/test_make_job_id.py @@ -0,0 +1,9 @@ +from bot.reminder import make_job_id + + +def test_make_job_id(): + """ + Tests the accuracy of the generation of scheduler id. + """ + assert make_job_id(user_chat_id=1, meeting_chat_id=2, meeting_topic_id=3) == "1_reminder_for_2_3" + assert make_job_id(user_chat_id=1, meeting_chat_id=2, meeting_topic_id=None) == "1_reminder_for_2" diff --git a/tests/test_non_handlers/test_update_reminders.py b/tests/test_non_handlers/test_update_reminders.py new file mode 100644 index 0000000..9a488ab --- /dev/null +++ b/tests/test_non_handlers/test_update_reminders.py @@ -0,0 +1,59 @@ +import pytest +from unittest.mock import AsyncMock + +from bot.reminder import update_reminders +from bot.state import ChatState, save_state +from tests.utils import TEST_USER, TEST_USER_2, register_2_users, set_meeting_time, set_reminder_period_2_users + + +@pytest.mark.asyncio +async def test_update_reminders(chat_state_2_users: ChatState): + """ + Tests the update reminders function with various users and chat_state configurations. + The main criteria - number of calls to scheduler. + """ + bot = AsyncMock() + username = TEST_USER.username + username_2 = TEST_USER_2.username + scheduler = AsyncMock() + send_message = AsyncMock() + + # Call without username should not change anything + await update_reminders(bot=bot, username=None, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + + # Call without registered users should not change anything + await update_reminders(bot=bot, username=username, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + + await register_2_users() + + # Calls without set meeting time and set reminder periods should not change anything + await update_reminders(bot=bot, username=username, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + await update_reminders(bot=bot, username=username_2, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + + await set_meeting_time(chat_state_2_users) + + # Calls with set meeting time but without set reminder periods should not change anything + await update_reminders(bot=bot, username=username, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + await update_reminders(bot=bot, username=username_2, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + + chat_state_2_users.users[TEST_USER.username].reminder_period = 1 + await save_state(chat_state_2_users) + + # 1 user set reminder period - 1 call to scheduler + await update_reminders(bot=bot, username=username, scheduler=scheduler, send_message=send_message) + await update_reminders(bot=bot, username=username_2, scheduler=scheduler, send_message=send_message) + assert scheduler.add_job.call_count == 1 + scheduler.reset_mock(return_value=True, side_effect=True) + + await set_reminder_period_2_users(chat_state_2_users) + + # 2 user set reminder period - 2 calls to scheduler + await update_reminders(bot=bot, username=username, scheduler=scheduler, send_message=send_message) + await update_reminders(bot=bot, username=username_2, scheduler=scheduler, send_message=send_message) + assert scheduler.add_job.call_count == 2 diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..2a139e2 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from aiogram.types import User, Chat + +from bot.constants import sample_time +from bot.state import create_user_pm, ChatState, save_state + +TEST_USER = User(id=11111111111111, is_bot=False, first_name="Test", + last_name="User", username="test", language_code="en-US", is_premium=False) + +TEST_USER_2 = User(id=2222222222222, is_bot=False, first_name="Test_2", + last_name="User_2", username="test_2", language_code="en-US", is_premium=False) + +TEST_CHAT = Chat(id=33333333333333, type="supergroup", is_forum=True, title="Test chat", description="Test chat") + +# TEST_USER's chat +TEST_CHAT_PRIVATE = Chat(id=44444444444444444, type="private", title="Test chat", description="Test chat") + +# TEST_USER_2's chat +TEST_CHAT_PRIVATE_2 = Chat(id=5555555555555, type="private", title="Test chat", description="Test chat") + + +async def register_2_users(): + await create_user_pm(TEST_USER.username, TEST_CHAT_PRIVATE.id) + await create_user_pm(TEST_USER_2.username, TEST_CHAT_PRIVATE_2.id) + + +async def set_reminder_period_2_users(chat_state: ChatState): + chat_state.users[TEST_USER.username].reminder_period = 1 + chat_state.users[TEST_USER_2.username].reminder_period = 1 + await save_state(chat_state) + + +async def set_meeting_time(chat_state: ChatState): + chat_state.meeting_time = datetime.fromisoformat(sample_time) + await save_state(chat_state) \ No newline at end of file