From 5bf3ecbdfdff60f7608522a9195f2906625d5267 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 3 Mar 2023 19:47:24 +0200 Subject: [PATCH] rewrite: Create guild `timezone` setting. --- data/migration/v12-13/migration.sql | 4 + src/babel/cog.py | 2 + src/core/data.py | 3 + src/modules/config/__init__.py | 3 + src/modules/config/general.py | 203 ++++++++++++++++++++++++++++ src/modules/tasklist/cog.py | 4 +- src/modules/tasklist/settings.py | 2 +- src/settings/setting_types.py | 10 +- 8 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 src/modules/config/general.py diff --git a/data/migration/v12-13/migration.sql b/data/migration/v12-13/migration.sql index 790acd32..9fbc785f 100644 --- a/data/migration/v12-13/migration.sql +++ b/data/migration/v12-13/migration.sql @@ -493,6 +493,10 @@ CREATE INDEX user_monthly_goals_users ON user_monthly_goals (userid); -- }}} +-- Timezone data {{{ +ALTER TABLE guild_config ADD COLUMN timezone TEXT; +-- }}} + INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration'); COMMIT; diff --git a/src/babel/cog.py b/src/babel/cog.py index 0b598878..e6d13f1d 100644 --- a/src/babel/cog.py +++ b/src/babel/cog.py @@ -11,6 +11,7 @@ from discord import app_commands as appcmds from meta import LionBot, LionCog, LionContext from meta.errors import UserInputError +from wards import low_management from settings import ModelData from settings.setting_types import StringSetting, BoolSetting @@ -225,6 +226,7 @@ class BabelCog(LionCog): ] ) @appcmds.guild_only() # Can be removed when attached as a subcommand + @cmds.check(low_management) async def cmd_configure_language( self, ctx: LionContext, language: Optional[str] = None, force_language: Optional[appcmds.Choice[int]] = None ): diff --git a/src/core/data.py b/src/core/data.py index eb15080c..bb44b548 100644 --- a/src/core/data.py +++ b/src/core/data.py @@ -138,6 +138,7 @@ class CoreData(Registry, name="core"): first_joined_at TIMESTAMPTZ DEFAULT now(), left_at TIMESTAMPTZ, locale TEXT, + timezone TEXT, force_locale BOOLEAN ); @@ -197,6 +198,8 @@ class CoreData(Registry, name="core"): first_joined_at = Timestamp() left_at = Timestamp() + timezone = String() + locale = String() force_locale = Bool() diff --git a/src/modules/config/__init__.py b/src/modules/config/__init__.py index ad3920eb..85f83444 100644 --- a/src/modules/config/__init__.py +++ b/src/modules/config/__init__.py @@ -7,4 +7,7 @@ babel = LocalBabel('config') async def setup(bot): from .cog import ConfigCog + from .general import GeneralSettingsCog + await bot.add_cog(ConfigCog(bot)) + await bot.add_cog(GeneralSettingsCog(bot)) diff --git a/src/modules/config/general.py b/src/modules/config/general.py new file mode 100644 index 00000000..ae8fe7ff --- /dev/null +++ b/src/modules/config/general.py @@ -0,0 +1,203 @@ +""" +Lion Module providing the "General" guild settings. + +Also provides a placeholder place to put guild settings before migrating to their correct modules. +""" +from typing import Optional +import datetime as dt + +import pytz +import discord +from discord.ext import commands as cmds +from discord import app_commands as appcmds + +from meta import LionBot, LionCog, LionContext +from meta.errors import UserInputError +from wards import low_management +from settings import ModelData +from settings.setting_types import TimezoneSetting +from settings.groups import SettingGroup + +from core.data import CoreData +from babel.translator import ctx_translator + +from . import babel + +_p = babel._p + + +class GeneralSettings(SettingGroup): + class Timezone(ModelData, TimezoneSetting): + """ + Guild timezone configuration. + + Exposed via `/configure general timezone:`, and the standard interface. + The `timezone` setting acts as the default timezone for all members, + and the timezone used to display guild-wide statistics. + """ + setting_id = 'timezone' + + _display_name = _p('guildset:timezone', "timezone") + _desc = _p( + 'guildset:timezone|desc', + "Guild timezone for statistics display." + ) + _long_desc = _p( + 'guildset:timezone|long_desc', + "Guild-wide timezone. " + "Used to determine start of the day for the leaderboards, " + "and as the default statistics timezone for members who have not set one." + ) + _default = 'UTC' + + _model = CoreData.Guild + _column = CoreData.Guild.timezone.name + + @property + def update_message(self): + t = ctx_translator.get().t + # TODO: update_message can state time in current timezone + return t(_p( + 'guildset:timezone|response', + "The guild timezone has been set to `{timezone}`." + )).format(timezone=self.data) + + @property + def set_str(self): + # TODO + return '' + + +class GeneralSettingsCog(LionCog): + depends = {'CoreCog'} + + def __init__(self, bot: LionBot): + self.bot = bot + self.settings = GeneralSettings() + + async def cog_load(self): + self.bot.core.guild_config.register_model_setting(GeneralSettings.Timezone) + + configcog = self.bot.get_cog('ConfigCog') + if configcog is None: + # TODO: Critical logging error + ... + self.crossload_group(self.configure_group, configcog.configure_group) + + @LionCog.placeholder_group + @cmds.hybrid_group("configure", with_app_command=False) + async def configure_group(self, ctx: LionContext): + # Placeholder configure group command. + ... + + @configure_group.command( + name=_p('cmd:configure_general', "general"), + description=_p('cmd:configure_general|desc', "General configuration panel") + ) + @appcmds.guild_only() + @cmds.check(low_management) + async def cmd_configure_general(self, ctx: LionContext, + timezone: Optional[str] = None): + t = self.bot.translator.t + + # Typechecker guards because they don't understand the check ward + if not ctx.guild: + return + if not ctx.interaction: + return + await ctx.interaction.response.defer(thinking=True) + + updated = [] # Possibly empty list of setting instances which were updated, with new data stored + error_embed = None + + if timezone is not None: + try: + timezone_setting = await self.settings.Timezone.from_string(ctx.guild.id, timezone) + updated.append(timezone_setting) + except UserInputError as err: + error_embed = discord.Embed( + colour=discord.Colour.brand_red(), + title=t(_p( + 'cmd:configure_general|parse_failure:timezone', + "Could not set the timezone!" + )), + description=err.msg + ) + + if error_embed is not None: + # User requested configuration updated, but we couldn't parse input + await ctx.reply(embed=error_embed) + elif updated: + # Save requested configuration updates + results = [] # List of "success" update responses for each updated setting + for to_update in updated: + # TODO: Again need a better way of batch writing + # Especially since most of these are on one model... + await to_update.write() + results.append(to_update.update_message) + # Post aggregated success message + success_embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'cmd:configure_general|success', + "Settings Updated!" + )), + description='\n'.join( + f"{self.bot.config.emojis.tick} {line}" for line in results + ) + ) + await ctx.reply(embed=success_embed) + # TODO: Trigger configuration panel update if listening UI. + else: + # Show general configuration panel UI + # TODO Interactive UI + embed = discord.Embed( + colour=discord.Colour.orange(), + title=t(_p( + 'cmd:configure_general|panel|title', + "General Configuration Panel" + )) + ) + embed.add_field( + **ctx.lguild.config.timezone.embed_field + ) + await ctx.reply(embed=embed) + + @cmd_configure_general.autocomplete('timezone') + async def cmd_configure_general_acmpl_timezone(self, interaction: discord.Interaction, partial: str): + """ + Autocomplete timezone options. + + Each option is formatted as timezone (current time). + Partial text is matched directly by case-insensitive substring. + """ + t = self.bot.translator.t + + timezones = pytz.all_timezones + matching = [tz for tz in timezones if partial.strip().lower() in tz.lower()][:25] + if not matching: + choices = [ + appcmds.Choice( + name=t(_p( + 'cmd:configure_general|acmpl:timezone|no_matching', + "No timezones matching '{input}'!" + )).format(input=partial), + value=partial + ) + ] + else: + choices = [] + for tz in matching: + timezone = pytz.timezone(tz) + now = dt.datetime.now(timezone) + nowstr = now.strftime("%H:%M") + name = t(_p( + 'cmd:configure_general|acmpl:timezone|choice', + "{tz} (Currently {now})" + )).format(tz=tz, now=nowstr) + choice = appcmds.Choice( + name=name, + value=tz + ) + choices.append(choice) + return choices diff --git a/src/modules/tasklist/cog.py b/src/modules/tasklist/cog.py index 5011b517..23d90411 100644 --- a/src/modules/tasklist/cog.py +++ b/src/modules/tasklist/cog.py @@ -702,7 +702,7 @@ class TasklistCog(LionCog): @configure_group.command( name=_p('cmd:configure_tasklist', "tasklist"), - description=_p('cmd:configure_tasklist|desc', "Configuration panel") + description=_p('cmd:configure_tasklist|desc', "Tasklist configuration panel") ) @appcmds.rename( reward=_p('cmd:configure_tasklist|param:reward', "reward"), @@ -712,7 +712,7 @@ class TasklistCog(LionCog): reward=TasklistSettings.task_reward._desc, reward_limit=TasklistSettings.task_reward_limit._desc ) - @appcmds.check(low_management) + @cmds.check(low_management) async def configure_tasklist_cmd(self, ctx: LionContext, reward: Optional[int] = None, reward_limit: Optional[int] = None): diff --git a/src/modules/tasklist/settings.py b/src/modules/tasklist/settings.py index aa164a42..191c7543 100644 --- a/src/modules/tasklist/settings.py +++ b/src/modules/tasklist/settings.py @@ -132,7 +132,7 @@ class TasklistConfigUI(LeoUI): # TODO: Cohesive edit _listening = {} - def __init__(self, bot: LionBot, settings: TasklistSettings, guildid: int, channelid: int,**kwargs): + def __init__(self, bot: LionBot, settings: TasklistSettings, guildid: int, channelid: int, **kwargs): super().__init__(**kwargs) self.bot = bot self.settings = settings diff --git a/src/settings/setting_types.py b/src/settings/setting_types.py index 57091b30..c7d1a2de 100644 --- a/src/settings/setting_types.py +++ b/src/settings/setting_types.py @@ -665,6 +665,10 @@ class TimezoneSetting(InteractiveSetting[ParentID, str, TZT]): """ Typed Setting ABC representing timezone information. """ + # TODO: Consider configuration UI for timezone by continent and country + # Do any continents have more than 25 countries? + # Maybe list e.g. Europe (Austria - Iceland) and Europe (Ireland - Ukraine) separately + # TODO Definitely need autocomplete here accepts = "A timezone name." _accepts = ( @@ -703,6 +707,7 @@ class TimezoneSetting(InteractiveSetting[ParentID, str, TZT]): """ Parse the user input into an integer. """ + # TODO: Localise # TODO: Another selection case. if not string: return None @@ -714,6 +719,9 @@ class TimezoneSetting(InteractiveSetting[ParentID, str, TZT]): timezone = timezones[0] elif timezones: raise UserInputError("Multiple matching timezones found!") + # TODO: Add a selector-message here instead of dying instantly + # Maybe only post a selector if there are less than 25 options! + # result = await ctx.selector( # "Multiple matching timezones found, please select one.", # timezones @@ -725,7 +733,7 @@ class TimezoneSetting(InteractiveSetting[ParentID, str, TZT]): "Please provide a TZ name from " "[this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)".format(string) ) from None - return timezone + return str(timezone) @classmethod def _format_data(cls, parent_id: ParentID, data, **kwargs):