diff --git a/classes/callbacks.py b/classes/callbacks.py index 0093c9b..4b111be 100644 --- a/classes/callbacks.py +++ b/classes/callbacks.py @@ -10,7 +10,7 @@ class CallbackLanguage: language: str @classmethod - def from_callback(cls, callback: CallbackQuery): + def from_callback(cls, callback: CallbackQuery) -> "CallbackLanguage": """Parse callback query and extract language data from it. ### Args: diff --git a/classes/garbage_entry.py b/classes/garbage_entry.py index 7c97b60..644c305 100644 --- a/classes/garbage_entry.py +++ b/classes/garbage_entry.py @@ -23,7 +23,7 @@ class GarbageEntry: date: datetime @classmethod - async def from_dict(cls, data: Mapping[str, Any]): + async def from_dict(cls, data: Mapping[str, Any]) -> "GarbageEntry": """Generate GarbageEntry object from the mapping provided ### Args: @@ -60,7 +60,7 @@ class GarbageEntry: ) @classmethod - async def from_record(cls, data: Mapping[str, Any]): + async def from_record(cls, data: Mapping[str, Any]) -> "GarbageEntry": locations = [ await Location.get(location_id) for location_id in data["locations"] ] diff --git a/classes/location.py b/classes/location.py index 5a29557..57497a3 100644 --- a/classes/location.py +++ b/classes/location.py @@ -28,7 +28,7 @@ class Location: timezone: Union[BaseTzInfo, DstTzInfo] @classmethod - async def get(cls, id: int): + async def get(cls, id: int) -> "Location": db_entry = await col_locations.find_one({"id": id}) if db_entry is None: @@ -40,7 +40,7 @@ class Location: return cls(**db_entry) @classmethod - async def find(cls, name: str): + async def find(cls, name: str) -> "Location": db_entry = await col_locations.find_one({"name": {"$regex": name}}) if db_entry is None: @@ -52,7 +52,7 @@ class Location: return cls(**db_entry) @classmethod - async def nearby(cls, lat: float, lon: float): + async def nearby(cls, lat: float, lon: float) -> "Location": db_entry = await col_locations.find_one({"location": {"$near": [lon, lat]}}) if db_entry is None: diff --git a/classes/pyrouser.py b/classes/pyrouser.py index abb4a50..2a91a8a 100644 --- a/classes/pyrouser.py +++ b/classes/pyrouser.py @@ -1,7 +1,9 @@ import logging from dataclasses import dataclass -from typing import Any, Union +from datetime import datetime, timedelta +from typing import Any, Mapping, Tuple, Union +import pytz from bson import ObjectId from classes.location import Location @@ -42,9 +44,9 @@ class PyroUser: enabled: bool = True, location_id: int = 0, offset: int = 1, - time_hour: int = 18, + time_hour: int = 16, time_minute: int = 0, - ): + ) -> "PyroUser": db_entry = await col_users.find_one({"id": id}) if db_entry is None: @@ -72,7 +74,7 @@ class PyroUser: return cls(**db_entry) @classmethod - async def from_dict(cls, **kwargs): + async def from_dict(cls, **kwargs) -> "PyroUser": if "location" in kwargs: try: kwargs["location"] = await Location.get(kwargs["location"]) # type: ignore @@ -80,40 +82,85 @@ class PyroUser: kwargs["location"] = None # type: ignore return cls(**kwargs) - async def update_locale(self, locale: Union[str, None]) -> None: + async def update_locale(self, locale: Union[str, None]) -> Union[str, None]: """Change user's locale stored in the database. ### Args: * locale (`Union[str, None]`): New locale to be set. """ + logger.debug("%s's locale has been set to %s", self.id, locale) + await col_users.update_one({"_id": self._id}, {"$set": {"locale": locale}}) - async def update_state(self, enabled: bool = False) -> None: + self.locale = locale + + return self.locale + + async def update_state(self, enabled: bool = False) -> bool: logger.debug("%s's state has been set to %s", self.id, enabled) + await col_users.update_one({"_id": self._id}, {"$set": {"enabled": enabled}}) - async def update_location(self, location_id: int = 0) -> None: + self.enabled = enabled + + return self.enabled + + async def update_location(self, location_id: int = 0) -> Location: logger.debug("%s's location has been set to %s", self.id, location_id) + await col_users.update_one( {"_id": self._id}, {"$set": {"location": location_id}} ) - async def update_offset(self, offset: int = 1) -> None: + location = await Location.get(location_id) + + # Execute if timezones of old and new locations are different + if self.location and (self.location.timezone.zone != location.timezone.zone): + # Get UTC time for selected reminder time + now_utc = datetime.now(pytz.utc).replace( + hour=self.time_hour, minute=self.time_minute, second=0, microsecond=0 + ) + + # Get the time for the reminder time of old and new location + local_old = now_utc.astimezone(self.location.timezone) + local_new = ( + location.timezone.localize(local_old.replace(tzinfo=None)) + ).astimezone(pytz.utc) + + # Update the time to match the new timezone + await self.update_time(hour=local_new.hour, minute=local_new.minute) + + self.location = location + + return self.location + + async def update_offset(self, offset: int = 1) -> int: logger.debug("%s's offset has been set to %s", self.id, offset) + await col_users.update_one({"_id": self._id}, {"$set": {"offset": offset}}) - async def update_time(self, hour: int = 18, minute: int = 0) -> None: + self.offset = offset + + return offset + + async def update_time(self, hour: int = 16, minute: int = 0) -> Tuple[int, int]: logger.debug("%s's time has been set to %s h. %s m.", self.id, hour, minute) + await col_users.update_one( {"_id": self._id}, {"$set": {"time_hour": hour, "time_minute": minute}} ) + self.time_hour = hour + self.time_minute = minute + + return self.time_hour, self.time_minute + async def delete(self) -> None: logger.debug("%s's data has been deleted", self.id) await col_users.delete_one({"_id": self._id}) - async def checkout(self) -> Any: + async def checkout(self) -> Mapping[str, Any]: logger.debug("%s's data has been checked out", self.id) db_entry = await col_users.find_one({"_id": self._id}) @@ -125,3 +172,60 @@ class PyroUser: del db_entry["_id"] # type: ignore return db_entry + + def get_reminder_date(self) -> datetime: + """Get next reminder date (year, month and day) + + ### Raises: + * `AttributeError`: Some attribute(s) are missing + + ### Returns: + * `datetime`: Datetime object of the next reminder date + """ + if self.location is None: + logger.warning( + "Could not get the reminder date for %s: User does not have some attribute(s) set", + self.id, + ) + raise AttributeError( + f"Could not get the reminder date for {self.id}: User does not have some attribute(s) set" + ) + + if not self.location.timezone: + logger.warning("Location %s does not have a timezone set", self.location.id) + + return ( + datetime.now(self.location.timezone or pytz.utc) + timedelta(days=1) + ).replace(hour=0, minute=0, second=0, microsecond=0) + + def get_reminder_time(self) -> datetime: + """Get reminder time (hour and minute) + + ### Raises: + * `AttributeError`: Some attribute(s) are missing + + ### Returns: + * `datetime`: Datetime object of the next reminder date + """ + if self.time_hour is None or self.time_minute is None or self.location is None: + logger.warning( + "Could not get the reminder time for %s: User does not have some attribute(s) set", + self.id, + ) + raise AttributeError( + f"Could not get the reminder time for {self.id}: User does not have some attribute(s) set" + ) + + if not self.location.timezone: + logger.warning("Location %s does not have a timezone set", self.location.id) + + return ( + datetime.now(pytz.utc) + .replace( + hour=self.time_hour, + minute=self.time_minute, + second=0, + microsecond=0, + ) + .astimezone(self.location.timezone or pytz.utc) + ) diff --git a/modules/reminder.py b/modules/reminder.py index e2151e0..aca992d 100644 --- a/modules/reminder.py +++ b/modules/reminder.py @@ -1,6 +1,7 @@ import logging -from datetime import datetime, timedelta +from datetime import datetime +import pytz from bson import json_util from libbot.pyrogram.classes import PyroClient @@ -9,13 +10,12 @@ from classes.location import Location from classes.pyrouser import PyroUser from modules.database import col_users from modules.database_api import col_entries -from modules.utils import from_utc logger = logging.getLogger(__name__) async def remind(app: PyroClient) -> None: - utcnow = datetime.utcnow() + utcnow = datetime.now(pytz.utc) logger.debug("Performing reminder lookup for %s (UTCNOW)", utcnow) @@ -40,12 +40,10 @@ async def remind(app: PyroClient) -> None: try: location: Location = await app.get_location(user.location.id) # type: ignore except ValueError: + logger.warning("Skipping reminder for %s due to invalid location", user.id) continue - user_date = from_utc( - datetime.utcnow() + timedelta(days=user.offset), - user.location.timezone.zone, - ).replace(hour=0, minute=0, second=0, microsecond=0) + user_date = user.get_reminder_date().replace(tzinfo=None) entries = await col_entries.find( { diff --git a/modules/utils.py b/modules/utils.py index aaece4b..19b0586 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -1,12 +1,13 @@ from datetime import datetime from typing import Union -from pytz import UTC -from pytz import timezone as pytz_timezone +import pytz def to_utc(date: datetime, timezone: Union[str, None] = None) -> datetime: - """Move timezone unaware datetime object to UTC timezone and return it. + """*DEPRECATED AND WILL BE REMOVED IN FUTURE RELEASES* + + Move timezone unaware datetime object to UTC timezone and return it. ### Args: * date (`datetime`): Datetime to be converted. @@ -16,11 +17,15 @@ def to_utc(date: datetime, timezone: Union[str, None] = None) -> datetime: * `datetime`: Timezone unaware datetime in UTC with timezone's offset applied to it. """ timezone = "UTC" if timezone is None else timezone - return pytz_timezone(timezone).localize(date).astimezone(UTC).replace(tzinfo=None) + return ( + pytz.timezone(timezone).localize(date).astimezone(pytz.utc).replace(tzinfo=None) + ) def from_utc(date: datetime, timezone: Union[str, None] = None) -> datetime: - """Move timezone unaware datetime object to the timezone specified and return it. + """*DEPRECATED AND WILL BE REMOVED IN FUTURE RELEASES* + + Move timezone unaware datetime object to the timezone specified and return it. ### Args: * date (`datetime`): Datetime to be converted. @@ -31,8 +36,5 @@ def from_utc(date: datetime, timezone: Union[str, None] = None) -> datetime: """ timezone = "UTC" if timezone is None else timezone return ( - pytz_timezone("UTC") - .localize(date) - .astimezone(pytz_timezone(timezone)) - .replace(tzinfo=None) + pytz.utc.localize(date).astimezone(pytz.timezone(timezone)).replace(tzinfo=None) ) diff --git a/plugins/commands/set_offset.py b/plugins/commands/set_offset.py index 30fce12..4fd2557 100644 --- a/plugins/commands/set_offset.py +++ b/plugins/commands/set_offset.py @@ -1,13 +1,13 @@ import logging from datetime import datetime +import pytz from convopyro import listen_message from pyrogram import filters from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove from classes.pyroclient import PyroClient from modules import custom_filters -from modules.utils import from_utc logger = logging.getLogger(__name__) @@ -65,18 +65,19 @@ async def command_set_offset(app: PyroClient, message: Message): logger.info("User %s has set offset to %s", user.id, offset) - garbage_time = from_utc( - datetime(1970, 1, 1, user.time_hour, user.time_minute), - None if user.location is None else user.location.timezone.zone, - ).strftime(app._("time", "formats")) + garbage_time = ( + datetime.now(pytz.utc) + .replace(hour=user.time_hour, minute=user.time_minute) + .astimezone(user.location.timezone or pytz.utc) + ) await answer.reply_text( app._("set_offset_finished", "messages", locale=user.locale).format( offset=offset, - time=garbage_time, - toggle_notice="" - if user.enabled - else app._("toggle", "messages", locale=user.locale), + time=garbage_time.strftime(app._("time", "formats", locale=user.locale)), + toggle_notice=( + "" if user.enabled else app._("toggle", "messages", locale=user.locale) + ), ), reply_markup=ReplyKeyboardRemove(), ) diff --git a/plugins/commands/set_time.py b/plugins/commands/set_time.py index 166b384..3d190e6 100644 --- a/plugins/commands/set_time.py +++ b/plugins/commands/set_time.py @@ -1,13 +1,13 @@ import logging from datetime import datetime +import pytz from convopyro import listen_message from pyrogram import filters from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove from classes.pyroclient import PyroClient from modules import custom_filters -from modules.utils import to_utc logger = logging.getLogger(__name__) @@ -55,31 +55,33 @@ async def command_set_time(app: PyroClient, message: Message): break - now = datetime.now() + # Time we got from the user + parsed_time = datetime.strptime(answer.text, "%H:%M") - parsed_time = datetime.strptime(answer.text, "%H:%M").replace( - year=now.year, month=now.month, day=now.day, second=0, microsecond=0 + # Datetime user means in their timezone + user_time = datetime.now(user.location.timezone).replace( + hour=parsed_time.hour, minute=parsed_time.minute, second=0, microsecond=0 ) - user_time = to_utc(parsed_time, user.location.timezone.zone) + # Datetime in user's timezone moved to UTC timezone + utc_time = user_time.astimezone(pytz.utc) - await user.update_time(hour=user_time.hour, minute=user_time.minute) + await user.update_time(hour=utc_time.hour, minute=utc_time.minute) logger.info( - "User %s has selected notification time of %s", + "User %s has selected notification time of %s (%s UTC)", user.id, user_time.strftime("%H:%M"), + utc_time.strftime("%H:%M"), ) - garbage_time = parsed_time.strftime(app._("time", "formats")) - await answer.reply_text( app._("set_time_finished", "messages", locale=user.locale).format( offset=user.offset, - time=garbage_time, - toggle_notice="" - if user.enabled - else app._("toggle", "messages", locale=user.locale), + time=user_time.strftime(app._("time", "formats", locale=user.locale)), + toggle_notice=( + "" if user.enabled else app._("toggle", "messages", locale=user.locale) + ), ), reply_markup=ReplyKeyboardRemove(), ) diff --git a/plugins/commands/setup.py b/plugins/commands/setup.py index 3790831..3f60ef9 100644 --- a/plugins/commands/setup.py +++ b/plugins/commands/setup.py @@ -1,5 +1,4 @@ import logging -from datetime import datetime from convopyro import listen_message from libbot import i18n @@ -11,7 +10,6 @@ from classes.pyroclient import PyroClient from modules import custom_filters from modules.search_name import search_name from modules.search_nearby import search_nearby -from modules.utils import from_utc logger = logging.getLogger(__name__) @@ -74,10 +72,9 @@ async def command_setup(app: PyroClient, message: Message): await user.update_location(location.id) - user_time = from_utc( - datetime(1970, 1, 1, user.time_hour, user.time_minute), - None if user.location is None else user.location.timezone.zone, - ).strftime(app._("time", "formats", locale=user.locale)) + user_time = user.get_reminder_time().strftime( + app._("time", "formats", locale=user.locale) + ) await message.reply_text( app._("setup_finished", "messages", locale=user.locale).format( diff --git a/plugins/commands/start.py b/plugins/commands/start.py index 12d2c6c..af11347 100644 --- a/plugins/commands/start.py +++ b/plugins/commands/start.py @@ -1,5 +1,3 @@ -from datetime import datetime - from convopyro import listen_message from pyrogram import filters from pyrogram.types import ( @@ -11,7 +9,6 @@ from pyrogram.types import ( from classes.pyroclient import PyroClient from modules import custom_filters -from modules.utils import from_utc @PyroClient.on_message( @@ -91,13 +88,15 @@ async def command_start(app: PyroClient, message: Message): await user.update_location(location.id) - user_time = from_utc( - datetime(1970, 1, 1, user.time_hour, user.time_minute), - None if user.location is None else user.location.timezone.zone, - ).strftime(app._("time", "formats", locale=user.locale)) + user_time = user.get_reminder_time().strftime( + app._("time", "formats", locale=user.locale) + ) + await answer.reply_text( app._("start_selection_yes", "messages", locale=user.locale).format( - name=location.name, offset=user.offset, time=user_time + name=location.name, + offset=user.offset, + time=user_time, ), reply_markup=ReplyKeyboardRemove(), ) diff --git a/plugins/commands/toggle.py b/plugins/commands/toggle.py index 1b67107..c19e232 100644 --- a/plugins/commands/toggle.py +++ b/plugins/commands/toggle.py @@ -1,11 +1,8 @@ -from datetime import datetime - from pyrogram import filters from pyrogram.types import Message from classes.pyroclient import PyroClient from modules import custom_filters -from modules.utils import from_utc @PyroClient.on_message( @@ -22,10 +19,12 @@ async def command_toggle(app: PyroClient, message: Message): ) return - user_time = from_utc( - datetime(1970, 1, 1, user.time_hour, user.time_minute), - None if user.location is None else user.location.timezone.zone, - ).strftime(app._("time", "formats")) + try: + user_time = user.get_reminder_time().strftime( + app._("time", "formats", locale=user.locale) + ) + except AttributeError: + user_time = "N/A" if user.location is None: await message.reply_text( diff --git a/plugins/commands/upcoming.py b/plugins/commands/upcoming.py index 881958d..e34e79c 100644 --- a/plugins/commands/upcoming.py +++ b/plugins/commands/upcoming.py @@ -1,5 +1,6 @@ -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta +import pytz from pyrogram import filters from pyrogram.types import Message @@ -23,11 +24,11 @@ async def command_upcoming(app: PyroClient, message: Message): date_min = ( datetime.now(user.location.timezone).replace(second=0, microsecond=0) - ).replace(tzinfo=timezone.utc) + ).replace(tzinfo=pytz.utc) date_max = ( datetime.now(user.location.timezone).replace(second=0, microsecond=0) + timedelta(days=30) - ).replace(tzinfo=timezone.utc) + ).replace(tzinfo=pytz.utc) entries = [ await GarbageEntry.from_record(entry) diff --git a/requirements.txt b/requirements.txt index 26c241b..93119a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ aiohttp~=3.9.5 apscheduler~=3.10.4 convopyro==0.5 mongodb-migrations==1.3.1 +pytz<=2023.2 tgcrypto==1.2.5 ujson>=5.0.0 uvloop==0.19.0 diff --git a/validation/examples/user.json b/validation/examples/user.json index b4765c9..db15f1a 100644 --- a/validation/examples/user.json +++ b/validation/examples/user.json @@ -4,6 +4,6 @@ "enabled": true, "location": 1, "offset": 1, - "time_hour": 18, + "time_hour": 16, "time_minute": 0 } \ No newline at end of file