From b0dcbaa727e1faa8b6aa044ba9e4bfe055718387 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 3 Mar 2023 15:35:08 +0200 Subject: [PATCH] rewrite (core): Split and refactor Lion and config. --- src/babel/cog.py | 12 +- src/core/cog.py | 19 +- src/core/guild_settings.py | 7 - src/core/lion.py | 211 ++++++++------------- src/core/user_settings.py | 7 - src/gui | 2 +- src/meta/LionContext.py | 9 +- src/modules/economy/cog.py | 4 +- src/modules/shop/shops/base.py | 8 +- src/modules/shop/shops/colours.py | 2 +- src/modules/statistics/graphics/goals.py | 4 +- src/modules/statistics/graphics/monthly.py | 4 +- src/modules/statistics/graphics/stats.py | 2 +- src/modules/statistics/graphics/weekly.py | 4 +- src/modules/statistics/ui/weeklymonthly.py | 6 +- src/modules/tasklist/cog.py | 4 +- src/settings/groups.py | 64 +++++++ src/utils/lib.py | 32 ++++ 18 files changed, 213 insertions(+), 188 deletions(-) delete mode 100644 src/core/guild_settings.py delete mode 100644 src/core/user_settings.py diff --git a/src/babel/cog.py b/src/babel/cog.py index b78e4650..0b598878 100644 --- a/src/babel/cog.py +++ b/src/babel/cog.py @@ -150,9 +150,9 @@ class BabelCog(LionCog): async def cog_load(self): if not self.bot.core: raise ValueError("CoreCog must be loaded first!") - self.bot.core.guild_settings.attach(LocaleSettings.ForceLocale) - self.bot.core.guild_settings.attach(LocaleSettings.GuildLocale) - self.bot.core.user_settings.attach(LocaleSettings.UserLocale) + self.bot.core.guild_config.register_model_setting(LocaleSettings.ForceLocale) + self.bot.core.guild_config.register_model_setting(LocaleSettings.GuildLocale) + self.bot.core.user_config.register_model_setting(LocaleSettings.UserLocale) async def cog_unload(self): pass @@ -180,12 +180,12 @@ class BabelCog(LionCog): """ locale = None if ctx.guild: - forced = ctx.alion.guild_settings['force_locale'].value - guild_locale = ctx.alion.guild_settings['guild_locale'].value + forced = ctx.lguild.config.get('force_locale').value + guild_locale = ctx.lguild.config.get('guild_locale').value if forced: locale = guild_locale - locale = locale or ctx.alion.user_settings['user_locale'].value + locale = locale or ctx.luser.config.get('user_locale').value if ctx.interaction: locale = locale or ctx.interaction.locale.value if ctx.guild: diff --git a/src/core/cog.py b/src/core/cog.py index b286e55f..a307ad60 100644 --- a/src/core/cog.py +++ b/src/core/cog.py @@ -11,8 +11,9 @@ from settings.groups import SettingGroup from .data import CoreData from .lion import Lions -from .guild_settings import GuildSettings -from .user_settings import UserSettings +from .lion_guild import GuildConfig +from .lion_member import MemberConfig +from .lion_user import UserConfig class CoreCog(LionCog): @@ -20,7 +21,7 @@ class CoreCog(LionCog): self.bot = bot self.data = CoreData() bot.db.load_registry(self.data) - self.lions = Lions(bot) + self.lions = Lions(bot, self.data) self.app_config: Optional[CoreData.AppConfig] = None self.bot_config: Optional[CoreData.BotConfig] = None @@ -35,19 +36,13 @@ class CoreCog(LionCog): # Some ModelSetting registries # These are for more convenient direct access - self.guild_settings = GuildSettings - self.user_settings = UserSettings + self.guild_config = GuildConfig + self.user_config = UserConfig + self.member_config = MemberConfig self.app_cmd_cache: list[discord.app_commands.AppCommand] = [] self.cmd_name_cache: dict[str, discord.app_commands.AppCommand] = {} - async def bot_check_once(self, ctx: LionContext): # type: ignore - lion = await self.lions.fetch(ctx.guild.id if ctx.guild else 0, ctx.author.id) - if ctx.guild: - await lion.touch_discord_models(ctx.author) # type: ignore # Type checker doesn't recognise guard - ctx.alion = lion - return True - async def cog_load(self): # Fetch (and possibly create) core data rows. conn = await self.bot.db.get_connection() diff --git a/src/core/guild_settings.py b/src/core/guild_settings.py deleted file mode 100644 index 407235c0..00000000 --- a/src/core/guild_settings.py +++ /dev/null @@ -1,7 +0,0 @@ -from settings.groups import ModelSettings, SettingDotDict -from .data import CoreData - - -class GuildSettings(ModelSettings): - _settings = SettingDotDict() - model = CoreData.Guild diff --git a/src/core/lion.py b/src/core/lion.py index 9adae3a3..3f9ea1fe 100644 --- a/src/core/lion.py +++ b/src/core/lion.py @@ -1,153 +1,96 @@ from typing import Optional from cachetools import LRUCache +import datetime import discord from meta import LionCog, LionBot, LionContext -from settings import InteractiveSetting -from utils.lib import utc_now from data import WeakCache from .data import CoreData -from .user_settings import UserSettings -from .guild_settings import GuildSettings - - -class Lion: - """ - A Lion is a high level representation of a Member in the LionBot paradigm. - - All members interacted with by the application should be available as Lions. - It primarily provides an interface to the User and Member data. - Lion also provides centralised access to various Member properties and methods, - that would normally be served by other cogs. - - Many Lion methods may only be used when the required cogs and extensions are loaded. - A Lion may exist without a Bot instance or a Member in cache, - although the functionality available will be more limited. - - There is no guarantee that a corresponding discord Member actually exists. - """ - __slots__ = ('bot', 'data', 'user_data', 'guild_data', '_member', '__weakref__') - - def __init__(self, bot: LionBot, data: CoreData.Member, user_data: CoreData.User, guild_data: CoreData.Guild): - self.bot = bot - self.data = data - self.user_data = user_data - self.guild_data = guild_data - - self._member: Optional[discord.Member] = None - - # Data properties - - @property - def key(self): - return (self.data.guildid, self.data.userid) - - @property - def guildid(self): - return self.data.guildid - - @property - def userid(self): - return self.data.userid - - @classmethod - def get(cls, guildid, userid): - return cls._cache_.get((guildid, userid), None) - - # ModelSettings interfaces - @property - def guild_settings(self): - return GuildSettings(self.guildid, self.guild_data, bot=self.bot) - - @property - def user_settings(self): - return UserSettings(self.userid, self.user_data, bot=self.bot) - - # Setting interfaces - # Each of these return an initialised member setting - - @property - def timezone(self): - pass - - @property - def locale(self): - pass - - # Time utilities - @property - def now(self): - """ - Returns current time-zone aware time for the member. - """ - pass - - # Discord data cache - async def touch_discord_models(self, member: discord.Member): - """ - Update the stored discord data from the given user or member object. - Intended to be used when we get member data from events that may not be available in cache. - """ - # Can we do these in one query? - if member.guild and (self.guild_data.name != member.guild.name): - await self.guild_data.update(name=member.guild.name) - - avatar_key = member.avatar.key if member.avatar else None - await self.user_data.update(avatar_hash=avatar_key, name=member.name, last_seen=utc_now()) - - if member.display_name != self.data.display_name: - await self.data.update(display_name=member.display_name) - - async def get_member(self) -> Optional[discord.Member]: - """ - Retrieve the member object for this Lion, if possible. - - If the guild or member cannot be retrieved, returns None. - """ - guild = self.bot.get_guild(self.guildid) - if guild is not None: - member = guild.get_member(self.userid) - if member is None: - try: - member = await guild.fetch_member(self.userid) - except discord.HTTPException: - pass - return member +from .lion_guild import LionGuild +from .lion_user import LionUser +from .lion_member import LionMember class Lions(LionCog): - def __init__(self, bot: LionBot): + def __init__(self, bot: LionBot, data: CoreData): self.bot = bot + self.data = data - # Full Lions cache - # Don't expire Lions with strong references - self._cache_: WeakCache[tuple[int, int], 'Lion'] = WeakCache(LRUCache(5000)) + # Caches + # Using WeakCache so strong references stay consistent + self.lion_guilds = WeakCache(LRUCache(2500)) + self.lion_users = WeakCache(LRUCache(2000)) + self.lion_members = WeakCache(LRUCache(5000)) - self._settings_: dict[str, InteractiveSetting] = {} - - async def fetch(self, guildid, userid) -> Lion: + async def bot_check_once(self, ctx: LionContext): """ - Fetch or create the given Member. - If the guild or user row doesn't exist, also creates it. - Relies on the core cog existing, to retrieve the core data. + Insert the high-level Lion objects into context before command execution. + + Creates the objects if they do not already exist. + Updates relevant saved data from the Discord models, + and updates last seen for the LionUser (for data lifetime). """ - # TODO: Find a way to reduce this to one query, while preserving cache - lion = self._cache_.get((guildid, userid)) - if lion is None: - if self.bot.core: - data = self.bot.core.data - else: - raise ValueError("Cannot fetch Lion before core module is attached.") + if ctx.guild: + # TODO: Consider doing all updates in one query, maybe with a View trigger on Member + lmember = ctx.lmember = await self.fetch_member(ctx.guild.id, ctx.author.id, ctx.author) + await lmember.touch_discord_model(ctx.author) - guild = await data.Guild.fetch_or_create(guildid) - user = await data.User.fetch_or_create(userid) - member = await data.Member.fetch_or_create(guildid, userid) - lion = Lion(self.bot, member, user, guild) - self._cache_[(guildid, userid)] = lion - return lion + ctx.luser = lmember.luser + await ctx.luser.touch_discord_model(ctx.author, seen=True) - def add_model_setting(self, setting: InteractiveSetting): - self._settings_[setting.__class__.__name__] = setting - return setting + ctx.lguild = lmember.lguild + await ctx.lguild.touch_discord_model(ctx.guild) + + ctx.alion = lmember + else: + ctx.lmember = ctx.alion = None + ctx.lguild = None + luser = ctx.luser = await self.fetch_user(ctx.author.id, ctx.author) + + await luser.touch_discord_model(ctx.author) + + ctx.alion = luser + return True + + async def fetch_user(self, userid, user: Optional[discord.User] = None) -> LionUser: + """ + Fetch the given LionUser, hitting cache if possible. + + Creates the LionUser if it does not exist. + """ + if (luser := self.lion_users.get(userid, None)) is None: + data = await self.data.User.fetch_or_create(userid) + luser = LionUser(self.bot, data, user=user) + self.lion_users[userid] = luser + return luser + + async def fetch_guild(self, guildid, guild: Optional[discord.Guild] = None) -> LionGuild: + """ + Fetch the given LionGuild, hitting cache if possible. + + Creates the LionGuild if it does not exist. + """ + if (lguild := self.lion_guilds.get(guildid, None)) is None: + data = await self.data.Guild.fetch_or_create(guildid) + lguild = LionGuild(self.bot, data, guild=guild) + self.lion_guilds[guildid] = lguild + return lguild + + async def fetch_member(self, guildid, userid, member: Optional[discord.Member] = None) -> LionMember: + """ + Fetch the given LionMember, using cache for data if possible. + + + Creates the LionGuild, LionUser, and LionMember if they do not already exist. + """ + # TODO: Can we do this more efficiently with one query, while keeping cache? Multiple joins? + key = (guildid, userid) + if (lmember := self.lion_members.get(key, None)) is None: + lguild = await self.fetch_guild(guildid, member.guild if member is not None else None) + luser = await self.fetch_user(userid, member) + data = await self.data.Member.fetch_or_create(guildid, userid) + lmember = LionMember(self.bot, data, lguild, luser, member) + self.lion_members[key] = lmember + return lmember diff --git a/src/core/user_settings.py b/src/core/user_settings.py deleted file mode 100644 index 8dc69b0a..00000000 --- a/src/core/user_settings.py +++ /dev/null @@ -1,7 +0,0 @@ -from settings.groups import ModelSettings, SettingDotDict -from .data import CoreData - - -class UserSettings(ModelSettings): - _settings = SettingDotDict() - model = CoreData.User diff --git a/src/gui b/src/gui index 4f3d5740..2beff63d 160000 --- a/src/gui +++ b/src/gui @@ -1 +1 @@ -Subproject commit 4f3d5740a3bdddec37a7f7f2e3b55e8a8e23b674 +Subproject commit 2beff63ddc42685f7ec153c6eb5adf536428da4c diff --git a/src/meta/LionContext.py b/src/meta/LionContext.py index ebef4de0..4f781381 100644 --- a/src/meta/LionContext.py +++ b/src/meta/LionContext.py @@ -8,7 +8,9 @@ from discord.ext.commands import Context if TYPE_CHECKING: from .LionBot import LionBot - from core.lion import Lion + from core.lion_member import LionMember + from core.lion_user import LionUser + from core.lion_guild import LionGuild logger = logging.getLogger(__name__) @@ -45,7 +47,10 @@ class LionContext(Context['LionBot']): Extends Context to add Lion-specific methods and attributes. Also adds several contextual wrapped utilities for simpler user during command invocation. """ - alion: 'Lion' + luser: 'LionUser' + lguild: 'LionGuild' + lmember: 'LionMember' + alion: 'LionUser | LionMember' def __repr__(self): parts = {} diff --git a/src/modules/economy/cog.py b/src/modules/economy/cog.py index 560b9e39..4daab0fa 100644 --- a/src/modules/economy/cog.py +++ b/src/modules/economy/cog.py @@ -415,7 +415,7 @@ class Economy(LionCog): ).on_conflict(ignore=True) else: # With only one target, we can take a simpler path, and make better use of local caches. - await self.bot.core.lions.fetch(ctx.guild.id, target.id) + await self.bot.core.lions.fetch_member(ctx.guild.id, target.id) # Now we are certain these members have a database row # Perform the appropriate action @@ -889,7 +889,7 @@ class Economy(LionCog): t = self.bot.translator.t Member = self.bot.core.data.Member - target_lion = await self.bot.core.lions.fetch(ctx.guild.id, target.id) + target_lion = await self.bot.core.lions.fetch_member(ctx.guild.id, target.id) # TODO: Add a "Send thanks" button to the DM? # Alternative flow could be waiting until the target user presses accept diff --git a/src/modules/shop/shops/base.py b/src/modules/shop/shops/base.py index f66aa43c..34faa398 100644 --- a/src/modules/shop/shops/base.py +++ b/src/modules/shop/shops/base.py @@ -11,7 +11,7 @@ from babel.translator import LazyStr from ..data import ShopData if TYPE_CHECKING: - from core.lion import Lion + from core.lion_member import LionMember class ShopCog(LionCog): @@ -65,7 +65,7 @@ class Customer: self.bot = bot self.data = shop_data - self.lion: 'Lion' = lion + self.lion: 'LionMember' = lion # A list of InventoryItems held by this customer self.inventory = inventory @@ -84,7 +84,7 @@ class Customer: @classmethod async def fetch(cls, bot: LionBot, shop_data: ShopData, guildid: int, userid: int): - lion = await bot.core.lions.fetch(guildid, userid) + lion = await bot.core.lions.fetch_member(guildid, userid) inventory = await shop_data.MemberInventoryInfo.fetch_inventory_info(guildid, userid) return cls(bot, shop_data, lion, inventory) @@ -92,7 +92,7 @@ class Customer: """ Refresh the data for this member. """ - self.lion = await self.bot.core.lions.fetch(self.guildid, self.userid) + self.lion = await self.bot.core.lions.fetch_member(self.guildid, self.userid) await self.lion.data.refresh() self.inventory = await self.data.MemberInventoryInfo.fetch_inventory_info(self.guildid, self.userid) return self diff --git a/src/modules/shop/shops/colours.py b/src/modules/shop/shops/colours.py index 58b7cbe1..93e301b1 100644 --- a/src/modules/shop/shops/colours.py +++ b/src/modules/shop/shops/colours.py @@ -184,7 +184,7 @@ class ColourShop(Shop): ) # Ensure the customer member actually exists - member = await self.customer.lion.get_member() + member = await self.customer.lion.fetch_member() if member is None: raise SafeCancellation( t(_p( diff --git a/src/modules/statistics/graphics/goals.py b/src/modules/statistics/graphics/goals.py index 3a64318a..48a9c7f7 100644 --- a/src/modules/statistics/graphics/goals.py +++ b/src/modules/statistics/graphics/goals.py @@ -15,7 +15,7 @@ async def get_goals_card( ): data: StatsData = bot.get_cog('StatsCog').data - lion = await bot.core.lions.fetch(guildid, userid) + lion = await bot.core.lions.fetch_member(guildid, userid) today = lion.today # Calculate periodid and select the correct model @@ -63,7 +63,7 @@ async def get_goals_card( sessions_complete = 0.5 # Get member profile - if member := await lion.get_member(): + if member := await lion.fetch_member(): username = (member.display_name, member.discriminator) avatar = member.avatar.key else: diff --git a/src/modules/statistics/graphics/monthly.py b/src/modules/statistics/graphics/monthly.py index 550edf11..f59e29f6 100644 --- a/src/modules/statistics/graphics/monthly.py +++ b/src/modules/statistics/graphics/monthly.py @@ -14,7 +14,7 @@ from ..lib import apply_month_offset async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int, mode: CardMode) -> MonthlyStatsCard: data: StatsData = bot.get_cog('StatsCog').data - lion = await bot.core.lions.fetch(guildid, userid) + lion = await bot.core.lions.fetch_member(guildid, userid) today = lion.today month_start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0) target = apply_month_offset(month_start, offset) @@ -77,7 +77,7 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int, monthly[i][day.day - 1] = stat / 3600 # Get member profile - if member := await lion.get_member(): + if member := await lion.fetch_member(): username = (member.display_name, member.discriminator) else: username = (lion.data.display_name, '#????') diff --git a/src/modules/statistics/graphics/stats.py b/src/modules/statistics/graphics/stats.py index 9aa62fff..2ce3d355 100644 --- a/src/modules/statistics/graphics/stats.py +++ b/src/modules/statistics/graphics/stats.py @@ -16,7 +16,7 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int): # TODO: Leaderboard rankings guildid = guildid or 0 - lion = await bot.core.lions.fetch(guildid, userid) + lion = await bot.core.lions.fetch_member(guildid, userid) # Calculate the period timestamps, i.e. start time for each summary period # TODO: Don't do the alltime one like this, not efficient anymore diff --git a/src/modules/statistics/graphics/weekly.py b/src/modules/statistics/graphics/weekly.py index 953b3c39..75d02e2b 100644 --- a/src/modules/statistics/graphics/weekly.py +++ b/src/modules/statistics/graphics/weekly.py @@ -12,7 +12,7 @@ from ..data import StatsData async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int, mode: CardMode) -> WeeklyStatsCard: data: StatsData = bot.get_cog('StatsCog').data - lion = await bot.core.lions.fetch(guildid, userid) + lion = await bot.core.lions.fetch_member(guildid, userid) today = lion.today week_start = today - timedelta(days=today.weekday()) - timedelta(weeks=offset) days = [week_start + timedelta(i) for i in range(-7, 7 if offset else (today.weekday() + 1))] @@ -34,7 +34,7 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int, day_stats.append(0) # Get member profile - if member := await lion.get_member(): + if member := await lion.fetch_member(): username = (member.display_name, member.discriminator) else: username = (lion.data.display_name, '#????') diff --git a/src/modules/statistics/ui/weeklymonthly.py b/src/modules/statistics/ui/weeklymonthly.py index 1222e1cb..7520e925 100644 --- a/src/modules/statistics/ui/weeklymonthly.py +++ b/src/modules/statistics/ui/weeklymonthly.py @@ -21,7 +21,7 @@ from babel.translator import ctx_translator, LazyStr from babel.utils import local_month from gui.cards import WeeklyGoalCard, WeeklyStatsCard, MonthlyGoalCard, MonthlyStatsCard from gui.base import CardMode -from core.lion import Lion +from core.lion_member import LionMember from ..graphics.weekly import get_weekly_card from ..graphics.monthly import get_monthly_card @@ -338,7 +338,7 @@ class WeeklyMonthlyUI(StatsUI): self.data: StatsData = bot.get_cog('StatsCog').data # State - self.lion: Optional[Lion] = None + self.lion: Optional[LionMember] = None self._stat_page: StatPage = StatPage.WEEKLY_VOICE self._week_offset = 0 @@ -859,7 +859,7 @@ class WeeklyMonthlyUI(StatsUI): """ self._original = interaction self._showing_global = False - self.lion = await self.bot.core.lions.fetch(self.guildid, self.userid) + self.lion = await self.bot.core.lions.fetch_member(self.guildid, self.userid) # TODO: Switch to using data cache in reload to calculate global/local diff --git a/src/modules/tasklist/cog.py b/src/modules/tasklist/cog.py index 407feeeb..5011b517 100644 --- a/src/modules/tasklist/cog.py +++ b/src/modules/tasklist/cog.py @@ -130,8 +130,8 @@ class TasklistCog(LionCog): async def cog_load(self): await self.data.init() - self.bot.core.guild_settings.attach(self.settings.task_reward) - self.bot.core.guild_settings.attach(self.settings.task_reward_limit) + self.bot.core.guild_config.register_model_setting(self.settings.task_reward) + self.bot.core.guild_config.register_model_setting(self.settings.task_reward_limit) # TODO: Better method for getting single load # Or better, unloading crossloaded group diff --git a/src/settings/groups.py b/src/settings/groups.py index 66764c7c..96e768c3 100644 --- a/src/settings/groups.py +++ b/src/settings/groups.py @@ -88,6 +88,70 @@ class ModelSetting(ModelData, BaseSetting): ... +class ModelConfig: + """ + A ModelConfig provides a central point of configuration for any object described by a single Model. + + An instance of a ModelConfig represents configuration for a single object + (given by a single row of the corresponding Model). + + The ModelConfig also supports registration of non-model configuration, + to support associated settings (e.g. list-settings) for the object. + + This is an ABC, and must be subclassed for each object-type. + """ + settings: SettingDotDict + _model_settings: set + model: Type[RowModel] + + def __init__(self, parent_id, row, **kwargs): + self.parent_id = parent_id + self.row = row + self.kwargs = kwargs + + @classmethod + def register_setting(cls, setting_cls): + """ + Decorator to register a non-model setting as part of the object configuration. + + The setting class may be re-accessed through the `settings` class attr. + + Subclasses may provide alternative access pathways to key non-model settings. + """ + cls.settings[setting_cls.setting_id] = setting_cls + return setting_cls + + @classmethod + def register_model_setting(cls, model_setting_cls): + """ + Decorator to register a model setting as part of the object configuration. + + The setting class may be accessed through the `settings` class attr. + + A fresh setting instance may also be retrieved (using cached data) + through the `get` instance method. + + Subclasses are recommended to provide model settings as properties + for simplified access and type checking. + """ + cls._model_settings.add(model_setting_cls.setting_id) + return cls.register_setting(model_setting_cls) + + def get(self, setting_id): + """ + Retrieve a freshly initialised copy of the given model-setting. + + The given `setting_id` must have been previously registered through `register_model_setting`. + This uses cached data, and so is not guaranteed to be up-to-date. + """ + if setting_id not in self._model_settings: + # TODO: Log + raise ValueError + setting_cls = self.settings[setting_id] + data = setting_cls._read_from_row(self.parent_id, self.row, **self.kwargs) + return setting_cls(self.parent_id, data, **self.kwargs) + + class ModelSettings: """ A ModelSettings instance aggregates multiple `ModelSetting` instances diff --git a/src/utils/lib.py b/src/utils/lib.py index 59802221..aa0afc4c 100644 --- a/src/utils/lib.py +++ b/src/utils/lib.py @@ -1,6 +1,7 @@ from typing import NamedTuple, Optional, Sequence, Union, overload, List import datetime import iso8601 # type: ignore +import pytz import re from contextvars import Context @@ -706,3 +707,34 @@ def parse_duration(string: str) -> Optional[int]: seconds += int(match.group('value')) * multiplier return seconds if found else None + + +class Timezoned: + """ + ABC mixin for objects with a set timezone. + + Provides several useful localised properties. + """ + __slots__ = () + + @property + def timezone(self) -> pytz.timezone: + """ + Must be implemented by the deriving class! + """ + raise NotImplementedError + + @property + def now(self): + """ + Return the current time localised to the object's timezone. + """ + return datetime.datetime.now(tz=self.timezone) + + @property + def today(self): + """ + Return the start of the day localised to the object's timezone. + """ + now = self.now + return now.replace(hour=0, minute=0, second=0, microsecond=0)