Merge pull request 'Possibly fixed #8' (#57) from profitroll/timezones-fix into dev

Reviewed-on: #57
This commit is contained in:
Profitroll 2024-05-31 00:25:53 +03:00
commit dc389ac1b7
14 changed files with 183 additions and 79 deletions

View File

@ -10,7 +10,7 @@ class CallbackLanguage:
language: str language: str
@classmethod @classmethod
def from_callback(cls, callback: CallbackQuery): def from_callback(cls, callback: CallbackQuery) -> "CallbackLanguage":
"""Parse callback query and extract language data from it. """Parse callback query and extract language data from it.
### Args: ### Args:

View File

@ -23,7 +23,7 @@ class GarbageEntry:
date: datetime date: datetime
@classmethod @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 """Generate GarbageEntry object from the mapping provided
### Args: ### Args:
@ -60,7 +60,7 @@ class GarbageEntry:
) )
@classmethod @classmethod
async def from_record(cls, data: Mapping[str, Any]): async def from_record(cls, data: Mapping[str, Any]) -> "GarbageEntry":
locations = [ locations = [
await Location.get(location_id) for location_id in data["locations"] await Location.get(location_id) for location_id in data["locations"]
] ]

View File

@ -28,7 +28,7 @@ class Location:
timezone: Union[BaseTzInfo, DstTzInfo] timezone: Union[BaseTzInfo, DstTzInfo]
@classmethod @classmethod
async def get(cls, id: int): async def get(cls, id: int) -> "Location":
db_entry = await col_locations.find_one({"id": id}) db_entry = await col_locations.find_one({"id": id})
if db_entry is None: if db_entry is None:
@ -40,7 +40,7 @@ class Location:
return cls(**db_entry) return cls(**db_entry)
@classmethod @classmethod
async def find(cls, name: str): async def find(cls, name: str) -> "Location":
db_entry = await col_locations.find_one({"name": {"$regex": name}}) db_entry = await col_locations.find_one({"name": {"$regex": name}})
if db_entry is None: if db_entry is None:
@ -52,7 +52,7 @@ class Location:
return cls(**db_entry) return cls(**db_entry)
@classmethod @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]}}) db_entry = await col_locations.find_one({"location": {"$near": [lon, lat]}})
if db_entry is None: if db_entry is None:

View File

@ -1,7 +1,9 @@
import logging import logging
from dataclasses import dataclass 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 bson import ObjectId
from classes.location import Location from classes.location import Location
@ -42,9 +44,9 @@ class PyroUser:
enabled: bool = True, enabled: bool = True,
location_id: int = 0, location_id: int = 0,
offset: int = 1, offset: int = 1,
time_hour: int = 18, time_hour: int = 16,
time_minute: int = 0, time_minute: int = 0,
): ) -> "PyroUser":
db_entry = await col_users.find_one({"id": id}) db_entry = await col_users.find_one({"id": id})
if db_entry is None: if db_entry is None:
@ -72,7 +74,7 @@ class PyroUser:
return cls(**db_entry) return cls(**db_entry)
@classmethod @classmethod
async def from_dict(cls, **kwargs): async def from_dict(cls, **kwargs) -> "PyroUser":
if "location" in kwargs: if "location" in kwargs:
try: try:
kwargs["location"] = await Location.get(kwargs["location"]) # type: ignore kwargs["location"] = await Location.get(kwargs["location"]) # type: ignore
@ -80,40 +82,85 @@ class PyroUser:
kwargs["location"] = None # type: ignore kwargs["location"] = None # type: ignore
return cls(**kwargs) 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. """Change user's locale stored in the database.
### Args: ### Args:
* locale (`Union[str, None]`): New locale to be set. * locale (`Union[str, None]`): New locale to be set.
""" """
logger.debug("%s's locale has been set to %s", self.id, locale) logger.debug("%s's locale has been set to %s", self.id, locale)
await col_users.update_one({"_id": self._id}, {"$set": {"locale": 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) logger.debug("%s's state has been set to %s", self.id, enabled)
await col_users.update_one({"_id": self._id}, {"$set": {"enabled": 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) logger.debug("%s's location has been set to %s", self.id, location_id)
await col_users.update_one( await col_users.update_one(
{"_id": self._id}, {"$set": {"location": location_id}} {"_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) logger.debug("%s's offset has been set to %s", self.id, offset)
await col_users.update_one({"_id": self._id}, {"$set": {"offset": 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) logger.debug("%s's time has been set to %s h. %s m.", self.id, hour, minute)
await col_users.update_one( await col_users.update_one(
{"_id": self._id}, {"$set": {"time_hour": hour, "time_minute": minute}} {"_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: async def delete(self) -> None:
logger.debug("%s's data has been deleted", self.id) logger.debug("%s's data has been deleted", self.id)
await col_users.delete_one({"_id": 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) logger.debug("%s's data has been checked out", self.id)
db_entry = await col_users.find_one({"_id": self._id}) db_entry = await col_users.find_one({"_id": self._id})
@ -125,3 +172,60 @@ class PyroUser:
del db_entry["_id"] # type: ignore del db_entry["_id"] # type: ignore
return db_entry 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)
)

View File

@ -1,6 +1,7 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime
import pytz
from bson import json_util from bson import json_util
from libbot.pyrogram.classes import PyroClient from libbot.pyrogram.classes import PyroClient
@ -9,13 +10,12 @@ from classes.location import Location
from classes.pyrouser import PyroUser from classes.pyrouser import PyroUser
from modules.database import col_users from modules.database import col_users
from modules.database_api import col_entries from modules.database_api import col_entries
from modules.utils import from_utc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def remind(app: PyroClient) -> None: async def remind(app: PyroClient) -> None:
utcnow = datetime.utcnow() utcnow = datetime.now(pytz.utc)
logger.debug("Performing reminder lookup for %s (UTCNOW)", utcnow) logger.debug("Performing reminder lookup for %s (UTCNOW)", utcnow)
@ -40,12 +40,10 @@ async def remind(app: PyroClient) -> None:
try: try:
location: Location = await app.get_location(user.location.id) # type: ignore location: Location = await app.get_location(user.location.id) # type: ignore
except ValueError: except ValueError:
logger.warning("Skipping reminder for %s due to invalid location", user.id)
continue continue
user_date = from_utc( user_date = user.get_reminder_date().replace(tzinfo=None)
datetime.utcnow() + timedelta(days=user.offset),
user.location.timezone.zone,
).replace(hour=0, minute=0, second=0, microsecond=0)
entries = await col_entries.find( entries = await col_entries.find(
{ {

View File

@ -1,12 +1,13 @@
from datetime import datetime from datetime import datetime
from typing import Union from typing import Union
from pytz import UTC import pytz
from pytz import timezone as pytz_timezone
def to_utc(date: datetime, timezone: Union[str, None] = None) -> datetime: 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: ### Args:
* date (`datetime`): Datetime to be converted. * 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. * `datetime`: Timezone unaware datetime in UTC with timezone's offset applied to it.
""" """
timezone = "UTC" if timezone is None else timezone 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: 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: ### Args:
* date (`datetime`): Datetime to be converted. * 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 timezone = "UTC" if timezone is None else timezone
return ( return (
pytz_timezone("UTC") pytz.utc.localize(date).astimezone(pytz.timezone(timezone)).replace(tzinfo=None)
.localize(date)
.astimezone(pytz_timezone(timezone))
.replace(tzinfo=None)
) )

View File

@ -1,13 +1,13 @@
import logging import logging
from datetime import datetime from datetime import datetime
import pytz
from convopyro import listen_message from convopyro import listen_message
from pyrogram import filters from pyrogram import filters
from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters from modules import custom_filters
from modules.utils import from_utc
logger = logging.getLogger(__name__) 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) logger.info("User %s has set offset to %s", user.id, offset)
garbage_time = from_utc( garbage_time = (
datetime(1970, 1, 1, user.time_hour, user.time_minute), datetime.now(pytz.utc)
None if user.location is None else user.location.timezone.zone, .replace(hour=user.time_hour, minute=user.time_minute)
).strftime(app._("time", "formats")) .astimezone(user.location.timezone or pytz.utc)
)
await answer.reply_text( await answer.reply_text(
app._("set_offset_finished", "messages", locale=user.locale).format( app._("set_offset_finished", "messages", locale=user.locale).format(
offset=offset, offset=offset,
time=garbage_time, time=garbage_time.strftime(app._("time", "formats", locale=user.locale)),
toggle_notice="" toggle_notice=(
if user.enabled "" if user.enabled else app._("toggle", "messages", locale=user.locale)
else app._("toggle", "messages", locale=user.locale), ),
), ),
reply_markup=ReplyKeyboardRemove(), reply_markup=ReplyKeyboardRemove(),
) )

View File

@ -1,13 +1,13 @@
import logging import logging
from datetime import datetime from datetime import datetime
import pytz
from convopyro import listen_message from convopyro import listen_message
from pyrogram import filters from pyrogram import filters
from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove from pyrogram.types import ForceReply, Message, ReplyKeyboardRemove
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters from modules import custom_filters
from modules.utils import to_utc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -55,31 +55,33 @@ async def command_set_time(app: PyroClient, message: Message):
break 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( # Datetime user means in their timezone
year=now.year, month=now.month, day=now.day, second=0, microsecond=0 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( logger.info(
"User %s has selected notification time of %s", "User %s has selected notification time of %s (%s UTC)",
user.id, user.id,
user_time.strftime("%H:%M"), user_time.strftime("%H:%M"),
utc_time.strftime("%H:%M"),
) )
garbage_time = parsed_time.strftime(app._("time", "formats"))
await answer.reply_text( await answer.reply_text(
app._("set_time_finished", "messages", locale=user.locale).format( app._("set_time_finished", "messages", locale=user.locale).format(
offset=user.offset, offset=user.offset,
time=garbage_time, time=user_time.strftime(app._("time", "formats", locale=user.locale)),
toggle_notice="" toggle_notice=(
if user.enabled "" if user.enabled else app._("toggle", "messages", locale=user.locale)
else app._("toggle", "messages", locale=user.locale), ),
), ),
reply_markup=ReplyKeyboardRemove(), reply_markup=ReplyKeyboardRemove(),
) )

View File

@ -1,5 +1,4 @@
import logging import logging
from datetime import datetime
from convopyro import listen_message from convopyro import listen_message
from libbot import i18n from libbot import i18n
@ -11,7 +10,6 @@ from classes.pyroclient import PyroClient
from modules import custom_filters from modules import custom_filters
from modules.search_name import search_name from modules.search_name import search_name
from modules.search_nearby import search_nearby from modules.search_nearby import search_nearby
from modules.utils import from_utc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -74,10 +72,9 @@ async def command_setup(app: PyroClient, message: Message):
await user.update_location(location.id) await user.update_location(location.id)
user_time = from_utc( user_time = user.get_reminder_time().strftime(
datetime(1970, 1, 1, user.time_hour, user.time_minute), app._("time", "formats", locale=user.locale)
None if user.location is None else user.location.timezone.zone, )
).strftime(app._("time", "formats", locale=user.locale))
await message.reply_text( await message.reply_text(
app._("setup_finished", "messages", locale=user.locale).format( app._("setup_finished", "messages", locale=user.locale).format(

View File

@ -1,5 +1,3 @@
from datetime import datetime
from convopyro import listen_message from convopyro import listen_message
from pyrogram import filters from pyrogram import filters
from pyrogram.types import ( from pyrogram.types import (
@ -11,7 +9,6 @@ from pyrogram.types import (
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters from modules import custom_filters
from modules.utils import from_utc
@PyroClient.on_message( @PyroClient.on_message(
@ -91,13 +88,15 @@ async def command_start(app: PyroClient, message: Message):
await user.update_location(location.id) await user.update_location(location.id)
user_time = from_utc( user_time = user.get_reminder_time().strftime(
datetime(1970, 1, 1, user.time_hour, user.time_minute), app._("time", "formats", locale=user.locale)
None if user.location is None else user.location.timezone.zone, )
).strftime(app._("time", "formats", locale=user.locale))
await answer.reply_text( await answer.reply_text(
app._("start_selection_yes", "messages", locale=user.locale).format( 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(), reply_markup=ReplyKeyboardRemove(),
) )

View File

@ -1,11 +1,8 @@
from datetime import datetime
from pyrogram import filters from pyrogram import filters
from pyrogram.types import Message from pyrogram.types import Message
from classes.pyroclient import PyroClient from classes.pyroclient import PyroClient
from modules import custom_filters from modules import custom_filters
from modules.utils import from_utc
@PyroClient.on_message( @PyroClient.on_message(
@ -22,10 +19,12 @@ async def command_toggle(app: PyroClient, message: Message):
) )
return return
user_time = from_utc( try:
datetime(1970, 1, 1, user.time_hour, user.time_minute), user_time = user.get_reminder_time().strftime(
None if user.location is None else user.location.timezone.zone, app._("time", "formats", locale=user.locale)
).strftime(app._("time", "formats")) )
except AttributeError:
user_time = "N/A"
if user.location is None: if user.location is None:
await message.reply_text( await message.reply_text(

View File

@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
import pytz
from pyrogram import filters from pyrogram import filters
from pyrogram.types import Message from pyrogram.types import Message
@ -23,11 +24,11 @@ async def command_upcoming(app: PyroClient, message: Message):
date_min = ( date_min = (
datetime.now(user.location.timezone).replace(second=0, microsecond=0) datetime.now(user.location.timezone).replace(second=0, microsecond=0)
).replace(tzinfo=timezone.utc) ).replace(tzinfo=pytz.utc)
date_max = ( date_max = (
datetime.now(user.location.timezone).replace(second=0, microsecond=0) datetime.now(user.location.timezone).replace(second=0, microsecond=0)
+ timedelta(days=30) + timedelta(days=30)
).replace(tzinfo=timezone.utc) ).replace(tzinfo=pytz.utc)
entries = [ entries = [
await GarbageEntry.from_record(entry) await GarbageEntry.from_record(entry)

View File

@ -2,6 +2,7 @@ aiohttp~=3.9.5
apscheduler~=3.10.4 apscheduler~=3.10.4
convopyro==0.5 convopyro==0.5
mongodb-migrations==1.3.1 mongodb-migrations==1.3.1
pytz<=2023.2
tgcrypto==1.2.5 tgcrypto==1.2.5
ujson>=5.0.0 ujson>=5.0.0
uvloop==0.19.0 uvloop==0.19.0

View File

@ -4,6 +4,6 @@
"enabled": true, "enabled": true,
"location": 1, "location": 1,
"offset": 1, "offset": 1,
"time_hour": 18, "time_hour": 16,
"time_minute": 0 "time_minute": 0
} }