From 59cb04cfac03e8f09970ad2bebb1264cdee5753a Mon Sep 17 00:00:00 2001 From: AriHoresh <78949378+AriHoresh@users.noreply.github.com> Date: Sun, 13 Mar 2022 14:37:55 +0200 Subject: [PATCH 01/22] License calrification --- LICENSE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/LICENSE.md b/LICENSE.md index 3a6fd6bb..66ce6cf2 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,8 @@ Copyright (c) 2022, Ari Horesh. All rights reserved. +StudyLion's code is provided for educational purposes only, and without warranty. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE From 3e50d2ed12f85e262961d059c3cd8e86b7df8d36 Mon Sep 17 00:00:00 2001 From: AriHoresh <78949378+AriHoresh@users.noreply.github.com> Date: Tue, 3 May 2022 08:58:38 +0200 Subject: [PATCH 02/22] Removde the private instance text --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 1691eed3..a7c5dfd1 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,6 @@ This bot was founder by [Ari Horesh](https://www.youtube.com/arihoresh) (Ari Hor ### Self Hosting -We offer private instances based on availablity (a private bot for your community) to server owners who want their own branding (logo, color scheme, private and seperate database, better response-rate, and customizability to the text itself). -If you are intrested, contact the founder at contact@arihoresh.com . You can self-host and fork the bot using the following steps, but beware that this version **does not include** our visual graphical user interface, which is only include in the custom private instances or our the public instance. @@ -38,7 +36,6 @@ We do not offer support for self-hosted bots, the code is provided as is without ## Features - - **Students Cards and Statistics** Allow users to create their own private student profile cards and set customs study field tags by using `!stats` and `!setprofile` From f898e6d2d440452c9adb4032d37eac3725b156c7 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 17 May 2022 23:09:42 +0300 Subject: [PATCH 03/22] style (help): Correct new commands. --- bot/modules/meta/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/meta/help.py b/bot/modules/meta/help.py index 4835636d..2925a863 100644 --- a/bot/modules/meta/help.py +++ b/bot/modules/meta/help.py @@ -10,7 +10,7 @@ from .lib import guide_link new_emoji = " 🆕" -new_commands = {'botconfig', 'sponsors'} +new_commands = {'brand', 'free', 'premium', 'gift', 'skin', 'addgems', 'removegems'} # Set the command groups to appear in the help group_hints = { From 997f259cf9d51effd315344d57897efa72632013 Mon Sep 17 00:00:00 2001 From: AriHoresh <78949378+AriHoresh@users.noreply.github.com> Date: Sat, 19 Aug 2023 09:50:36 +0200 Subject: [PATCH 04/22] Update README.md --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a7c5dfd1..e3d35dd7 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ StudyLion is a Discord bot that tracks members' study and work time while offeri -[**Invite StudyLion here**](https://discord.studylions.com/invite "here"), and get started with `!help`. +[**Invite StudyLion here**](https://discord.com/oauth2/authorize?client_id=889078613817831495&permissions=8&scope=bot), and get started with `/help`. -Join the [**support server**](https://discord.gg/studylions "support server") to contact us if you need help configuring the bot on your server, or start a [**discussion**](https://github.com/StudyLions/StudyLion/discussions "disscussion") to report issues and bugs. +Join the [**support server**](https://discord.gg/the-study-lions-780195610154237993) to contact us if you need help configuring the bot on your server, or start a [**discussion**](https://github.com/StudyLions/StudyLion/discussions "disscussion") to report issues and bugs. @@ -18,12 +18,11 @@ In the past couple of years, we noticed a new trend on Discord – instead of be -This bot was founder by [Ari Horesh](https://www.youtube.com/arihoresh) (Ari Horesh#0001) to support these forming study communities and allow students all over the world to study better. +This bot was founder by [Ari Horesh](https://www.youtube.com/arihoresh) (@AriHoresh) to support these forming study communities and allow students all over the world to study better. ### Self Hosting - -You can self-host and fork the bot using the following steps, but beware that this version **does not include** our visual graphical user interface, which is only include in the custom private instances or our the public instance. +You can self-host and fork the bot using the following steps, but beware that we do not provide support for self-hosted instances. If you are interested in a private managed instance, contact Ari at contact@arihoresh.com Follow the steps below to self-host the bot. - Clone the repo recursively (which makes sure to include the cmdClient submodule, otherwise you need to initialise it separately) From e4849c1c1fd6da36102b074562cb7092c9ff13de Mon Sep 17 00:00:00 2001 From: AriHoresh <78949378+AriHoresh@users.noreply.github.com> Date: Sat, 19 Aug 2023 10:00:51 +0200 Subject: [PATCH 05/22] Update LICENSE.md --- LICENSE.md | 53 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index 66ce6cf2..b797f650 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,12 +1,45 @@ -Copyright (c) 2022, Ari Horesh. -All rights reserved. +StudyLion Open Source License -StudyLion's code is provided for educational purposes only, and without warranty. +Copyright (c) 2023, Ari Horesh. All rights reserved. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +### 1. Definitions + +- "Software": Refers to the Discord bot named "StudyLion" and associated documentation, source code, scripts, assets, and other related materials. +- "Educational Use": Means utilization of the Software primarily for learning, teaching, training, research, or development. +- "Non-commercial Use": Describes an application of the Software where there's no expectation or realization of direct or indirect monetary compensation. + +### 2. Grant of License + +- Under the terms and conditions of this license, the licensor grants a worldwide, non-exclusive, royalty-free, non-transferable license to: + - Use the Software. + - Reproduce the Software. + - Modify the Software, creating derivative works based on the Software. + - Distribute the unmodified Software for Educational and Non-commercial Use. + +### 3. Restrictions + +- Redistribution, whether modified or unmodified, must: + - Preserve the above copyright notice. + - Incorporate this list of conditions. + - Include the following disclaimers. + +- You must not: + - Use the name, trademarks, service marks, or names of the Software or its contributors to endorse or promote derivative products without prior written consent. + - Deploy the Software or any derivative works thereof for commercial purposes or any context leading to financial gain. + - Assert proprietary rights or assign authorship of the original Software to any entity other than the original authors. + - Grant sublicenses for the Software. + +### 4. Contributions + +- Any contributions made to the Software by third parties shall be subject to this license. The contributor grants the licensor a non-exclusive, perpetual, irrevocable license to any such contributions. + +### 5. Termination + +- If you breach any terms of this license, your rights will terminate automatically. Once terminated, you must: + - Halt all utilization of the Software. + - Destroy or delete all copies of the Software in your possession or control. + +### 6. Disclaimer and Limitation of Liability + +- THE SOFTWARE IS OFFERED "AS IS", WITHOUT ANY GUARANTEES OR CLAIMS OF EFFICACY. NO WARRANTIES, IMPLIED OR EXPLICIT, ARE PROVIDED. THIS INCLUDES, BUT IS NOT RESTRICTED TO, WARRANTIES OF MERCHANDISE, FITNESS FOR A SPECIFIC PURPOSE, AND NON-INFRINGEMENT. +- UNDER NO CONDITION SHALL THE AUTHORS, COPYRIGHT HOLDERS, OR CONTRIBUTORS BE ACCOUNTABLE FOR ANY CLAIMS, DAMAGES, OR OTHER LIABILITIES, WHETHER RESULTING FROM CONTRACT, TORT, NEGLIGENCE, OR ANY OTHER LEGAL THEORY, EMERGING FROM, OUT OF, OR CONNECTED WITH THE SOFTWARE OR ITS USE. From e6df4a0215c0f4c12feca326f21cbdd85b471522 Mon Sep 17 00:00:00 2001 From: AriHoresh <78949378+AriHoresh@users.noreply.github.com> Date: Mon, 21 Aug 2023 13:30:43 +0200 Subject: [PATCH 06/22] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e3d35dd7..ac7e6da7 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This bot was founder by [Ari Horesh](https://www.youtube.com/arihoresh) (@AriHor ### Self Hosting -You can self-host and fork the bot using the following steps, but beware that we do not provide support for self-hosted instances. If you are interested in a private managed instance, contact Ari at contact@arihoresh.com +You can self-host and fork the bot using the following steps, but beware that we do not provide support for self-hosted instances. If you are interested in a privately managed instance (affordable paid service), contact Ari at contact@arihoresh.com Follow the steps below to self-host the bot. - Clone the repo recursively (which makes sure to include the cmdClient submodule, otherwise you need to initialise it separately) From 97d6db6347c85493035cf7e1212a5f102cb802f0 Mon Sep 17 00:00:00 2001 From: AriHoresh <78949378+AriHoresh@users.noreply.github.com> Date: Thu, 7 Sep 2023 16:21:02 +0200 Subject: [PATCH 07/22] Update README.md --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ac7e6da7..9f142b82 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -## StudyLion - Discord Study & Productivity Bot +## LionBot (formerlyStudyLion) - Discord Study & Productivity Bot -StudyLion is a Discord bot that tracks members' study and work time while offering members the ability to view their statistics and use productivity tools such as: To-do lists, pomodoro timers, reminders, and much more. +LionBot is a Discord bot that tracks members' study and work time while offering members the ability to view their statistics and use productivity tools such as: To-do lists, pomodoro timers, reminders, and much more. -[**Invite StudyLion here**](https://discord.com/oauth2/authorize?client_id=889078613817831495&permissions=8&scope=bot), and get started with `/help`. +[**Invite LionBot here**](https://discord.com/oauth2/authorize?client_id=889078613817831495&permissions=8&scope=bot), and get started with `/help`. -Join the [**support server**](https://discord.gg/the-study-lions-780195610154237993) to contact us if you need help configuring the bot on your server, or start a [**discussion**](https://github.com/StudyLions/StudyLion/discussions "disscussion") to report issues and bugs. +Join the [**support server**](https://discord.gg/the-study-lions-780195610154237993) to contact us if you need help configuring the bot on your server, or start a [**discussion**](https://github.com/LionBots/LionBot/discussions "disscussion") to report issues and bugs. @@ -104,8 +104,8 @@ Punish cheaters, audit-log, welcome message, and so much more using our full-sca ### Tutorials -A command list and general documentation for StudyLion may be found using the `!help` command, and documentation for a specific command, e.g. `config`, may be found with `!help config`. +A command list and general documentation for LionBot may be found using the `!help` command, and documentation for a specific command, e.g. `config`, may be found with `!help config`. -Make sure to check the [full documentation](https://www.notion.so/izabellakis/StudyLion-Bot-Tutorials-f493268fcd12436c9674afef2e151707 "StudyLion Tutorial") to stay updated. +Make sure to check the [full documentation](https://www.notion.so/izabellakis/LionBot-Bot-Tutorials-f493268fcd12436c9674afef2e151707 "LionBot Tutorial") to stay updated. From d77f164aacb0a92fcac045817b48a349496b035f Mon Sep 17 00:00:00 2001 From: AriHoresh <78949378+AriHoresh@users.noreply.github.com> Date: Thu, 7 Sep 2023 16:21:12 +0200 Subject: [PATCH 08/22] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f142b82..9a35e94d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -## LionBot (formerlyStudyLion) - Discord Study & Productivity Bot +## LionBot (formerly StudyLion) - Discord Study & Productivity Bot LionBot is a Discord bot that tracks members' study and work time while offering members the ability to view their statistics and use productivity tools such as: To-do lists, pomodoro timers, reminders, and much more. From 00162e9e73ce70313e761c57db0bf2e88b495e34 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 26 Oct 2023 09:48:11 +0300 Subject: [PATCH 09/22] fix(moderation): Consistent ticket ordering. --- src/modules/moderation/ticket.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/modules/moderation/ticket.py b/src/modules/moderation/ticket.py index 6aff4aac..bdc98330 100644 --- a/src/modules/moderation/ticket.py +++ b/src/modules/moderation/ticket.py @@ -5,6 +5,7 @@ from typing import Optional import discord from core.lion_guild import LionGuild +from data.queries import ORDER from meta import LionBot from utils.lib import MessageArgs, jumpto, strfdelta, utc_now from utils.monitor import TaskMonitor @@ -86,7 +87,9 @@ class Ticket: instantiate the correct classes. """ registry: ModerationData = bot.db.registries['ModerationData'] - rows = await registry.Ticket.fetch_where(*args, **kwargs) + rows = await registry.Ticket.fetch_where(*args, **kwargs).order_by( + 'created_at', ORDER.DESC, + ) tickets = [] if rows: guildids = set(row.guildid for row in rows) From 90c86460b6fcc935cf6081da80cf521ce276f644 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 26 Oct 2023 09:48:40 +0300 Subject: [PATCH 10/22] fix(sysadmin): Limit module acmpl to 25. --- src/modules/sysadmin/exec_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/sysadmin/exec_cog.py b/src/modules/sysadmin/exec_cog.py index 13acfa4d..ff0b8cbb 100644 --- a/src/modules/sysadmin/exec_cog.py +++ b/src/modules/sysadmin/exec_cog.py @@ -431,7 +431,7 @@ class Exec(LionCog): results = [ appcmd.Choice(name=f"No extensions found matching {partial}", value="None") ] - return results + return results[:25] @commands.hybrid_command( name=_('shutdown'), From c41374bbaace5e894da6bd9028a94cf5c6ecd90a Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 26 Oct 2023 09:50:07 +0300 Subject: [PATCH 11/22] feat(premium): Implement gem API and admin. --- config/example-bot.conf | 2 +- src/modules/__init__.py | 1 + src/modules/premium/__init__.py | 10 + src/modules/premium/cog.py | 705 +++++++++++++++++++++++++ src/modules/premium/data.py | 92 ++++ src/modules/premium/errors.py | 19 + src/modules/premium/ui/premium.py | 0 src/modules/premium/ui/transactions.py | 198 +++++++ 8 files changed, 1026 insertions(+), 1 deletion(-) create mode 100644 src/modules/premium/__init__.py create mode 100644 src/modules/premium/cog.py create mode 100644 src/modules/premium/data.py create mode 100644 src/modules/premium/errors.py create mode 100644 src/modules/premium/ui/premium.py create mode 100644 src/modules/premium/ui/transactions.py diff --git a/config/example-bot.conf b/config/example-bot.conf index ec7bb97b..feb33705 100644 --- a/config/example-bot.conf +++ b/config/example-bot.conf @@ -17,7 +17,7 @@ invite_bot = [ENDPOINTS] guild_log = -gem_transaction = +gem_log = [LOGGING] log_file = bot.log diff --git a/src/modules/__init__.py b/src/modules/__init__.py index d23dcd7c..74964ff5 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -20,6 +20,7 @@ active = [ '.meta', '.sponsors', '.topgg', + '.premium', '.test', ] diff --git a/src/modules/premium/__init__.py b/src/modules/premium/__init__.py new file mode 100644 index 00000000..222b76bb --- /dev/null +++ b/src/modules/premium/__init__.py @@ -0,0 +1,10 @@ +import logging +from babel.translator import LocalBabel + +babel = LocalBabel('premium') +logger = logging.getLogger(__name__) + + +async def setup(bot): + from .cog import PremiumCog + await bot.add_cog(PremiumCog(bot)) diff --git a/src/modules/premium/cog.py b/src/modules/premium/cog.py new file mode 100644 index 00000000..e88a8611 --- /dev/null +++ b/src/modules/premium/cog.py @@ -0,0 +1,705 @@ +from typing import Optional +import asyncio + +import discord +from discord.ext import commands as cmds +import discord.app_commands as appcmds + +from discord.ui.button import Button, ButtonStyle +from discord.ui.text_input import TextInput, TextStyle + +from meta import LionCog, LionBot, LionContext +from meta.errors import SafeCancellation, UserInputError +from meta.logger import log_wrap +from utils.lib import utc_now +from utils.ui import FastModal +from wards import sys_admin_ward +from constants import MAX_COINS + +from . import logger, babel +from .data import PremiumData, GemTransactionType +from .ui.transactions import TransactionList +from .errors import GemTransactionFailed, BalanceTooLow, BalanceTooHigh + +_p = babel._p + + +class PremiumCog(LionCog): + buy_gems_link = "https://lionbot.org/donate" + + def __init__(self, bot: LionBot): + self.bot = bot + self.data: PremiumData = bot.db.load_registry(PremiumData()) + + self.gem_logger: Optional[discord.Webhook] = None + + async def cog_load(self): + await self.data.init() + + if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None: + self.crossload_group(self.leo_group, leo_setting_cog.leo_group) + + if (gem_log_url := self.bot.config.endpoints.get('gem_log', None)) is not None: + self.gem_logger = discord.Webhook.from_url(gem_log_url, session=self.bot.web_client) + + + # ----- API ----- + def buy_gems_buttons(self) -> Button: + t = self.bot.translator.t + + button = Button( + style=ButtonStyle.link, + label=t(_p( + 'button:gems|label', + "Buy Gems" + )), + emoji=self.bot.config.emojis.gem, + url=self.buy_gems_link, + ) + return button + + async def get_gem_balance(self, userid: int) -> int: + """ + Get the up-to-date gem balance for this user. + + Creates the User row if it does not already exist. + """ + record = await self.bot.core.data.User.fetch(userid, cached=False) + if record is None: + record = await self.bot.core.data.User.create(userid=userid) + return record.gems + + async def get_gift_count(self, userid: int) -> int: + """ + Compute the number of gifts this user has sent, by counting Transaction rows. + """ + record = await self.data.GemTransaction.table.select_where( + from_account=userid, + transaction_type=GemTransactionType.GIFT, + ).select( + gift_count='COUNT(*)' + ).with_no_adapter() + + return record[0]['gift_count'] or 0 + + async def is_premium_guild(self, guildid: int) -> bool: + """ + Check whether the given guild currently has premium status. + """ + row = await self.data.PremiumGuild.fetch(guildid) + now = utc_now() + + premium = (row is not None) and (row.premium_until > now) + return premium + + @log_wrap(isolate=True) + async def _add_gems(self, userid: int, amount: int): + """ + Transaction helper method to atomically add `amount` gems to account `userid`, + creating the account if required. + + Do not use this method for a gem transaction. Use `gem_transaction` instead. + """ + async with self.bot.db.connection() as conn: + self.bot.db.conn = conn + async with conn.transaction(): + model = self.bot.core.data.User + rows = await model.table.update_where(userid=userid).set(gems=model.gems + amount) + if not rows: + # User does not exist, create it + if amount < 0: + raise BalanceTooLow + if amount > MAX_COINS: + raise BalanceTooHigh + row = (await model.create(userid=userid, gems=amount)).data + else: + row = rows[0] + + if row['gems'] < 0: + raise BalanceTooLow + + async def gem_transaction( + self, + transaction_type: GemTransactionType, + *, + actorid: int, + from_account: Optional[int], to_account: Optional[int], + amount: int, description: str, + note: Optional[str] = None, reference: Optional[str] = None, + ) -> PremiumData.GemTransaction: + """ + Perform a gem transaction with the given parameters. + + This atomically creates a row in the 'gem_transactions' table, + updates the account balances, + and posts in the gem audit log. + + Parameters + ---------- + transaction_type: GemTransactionType + The type of transaction. + actorid: int + The userid of the actor who initiated this transaction. + Automatic actions (e.g. webhook triggered) may have their own unique id. + from_account: Optional[int] + The userid of the source account. + May be `None` if there is no source account (e.g. manual modification by admin). + to_account: Optional[int] + The userid of the destination account. + May be `None` if there is no destination account. + amount: int + The number of LionGems to transfer. + description: str + An informative description of the transaction for auditing purposes. + Should include the pathway (e.g. command) through which the transaction was executed. + note: Optional[str] + Optional user-readable note added by the actor. + Usually attached in a notification visible by the target. + (E.g. thanks message from system/admin, or note attached to gift.) + reference: str + Optional admin-readable transaction reference. + This may be the message link of a command message, + or an external id/reference for an automatic transaction. + + Raises + ------ + BalanceTooLow: + Raised if either source or target account would go below 0. + """ + async with self.bot.db.connection() as conn: + self.bot.db.conn = conn + async with conn.transaction(): + if from_account is not None: + await self._add_gems(from_account, -amount) + if to_account is not None: + await self._add_gems(to_account, amount) + + row = await self.data.GemTransaction.create( + transaction_type=transaction_type, + actorid=actorid, + from_account=from_account, + to_account=to_account, + amount=amount, + description=description, + note=note, + reference=reference, + ) + logger.info( + f"LionGem Transaction performed. Transaction data: {row!r}" + ) + await self.audit_log(row) + return row + + async def audit_log(self, row: PremiumData.GemTransaction): + """ + Log the provided gem transaction to the global gem audit log. + + If this fails, or the audit log does not exist, logs a warning. + """ + posted = False + if self.gem_logger is not None: + embed = discord.Embed( + colour=discord.Colour.orange(), + title=f"Gem Transaction #{row.transactionid}", + timestamp=row._timestamp, + ) + embed.add_field(name="Type", value=row.transaction_type.name) + embed.add_field(name="Amount", value=str(row.amount)) + embed.add_field(name="Actor", value=f"<@{row.actorid}>") + embed.add_field(name="From Account", value=f"<@{row.from_account}>" if row.from_account else 'None') + embed.add_field(name="To Account", value=f"<@{row.to_account}>" if row.to_account else 'None') + embed.add_field(name='Description', value=str(row.description), inline=False) + if row.note: + embed.add_field(name='Note', value=str(row.note), inline=False) + if row.reference: + embed.add_field(name='Reference', value=str(row.reference), inline=False) + + try: + await self.gem_logger.send(embed=embed) + except discord.HTTPException: + pass + + if not posted: + logger.warning( + f"Missed gem audit logging for gem transaction: {row!r}" + ) + + # ----- User Commands ----- + @cmds.hybrid_command( + name=_p('cmd:free', "free"), + description=_p( + 'cmd:free|desc', + "Get free LionGems!" + ) + ) + async def cmd_free(self, ctx: LionContext): + t = self.bot.translator.t + content = t(_p( + 'cmd:free|embed|description', + "You can get free LionGems by sharing our project on your Discord server and social media!\n" + "If you have well-established, or YouTube, Instagram, and TikTok accounts," + " we will reward you for creating videos and content about the bot.\n" + "If you have a big server, you can promote our project and get LionGems in return.\n" + "For more details, contact `arihoresh` or open a Ticket in the [support server](https://discord.gg/studylions)." + )) + thumb = "https://cdn.discordapp.com/attachments/890619584158265405/972791204498530364/Untitled_design_44.png" + title = t(_p( + 'cmd:free|embed|title', + "Get FREE LionGems!" + )) + embed = discord.Embed( + title=title, + description=content, + colour=0x41f097 + ) + embed.set_thumbnail(url=thumb) + + await ctx.reply(embed=embed) + + @cmds.hybrid_command( + name=_p('cmd:gift', "gift"), + description=_p( + 'cmd:gift|desc', + "Gift your LionGems to another user!" + ) + ) + @appcmds.rename( + user=_p('cmd:gift|param:user', "user"), + amount=_p('cmd:gift|param:amount', "amount"), + note=_p('cmd:gift|param:note', "note"), + ) + @appcmds.describe( + user=_p( + 'cmd:gift|param:user|desc', + "User to which you want to gift your LionGems." + ), + amount=_p( + 'cmd:gift|param:amount|desc', + "Number of LionGems to gift." + ), + note=_p( + 'cmd:gift|param:note|desc', + "Optional note to attach to your gift." + ), + ) + async def cmd_gift(self, ctx: LionContext, + user: discord.User, + amount: appcmds.Range[int, 1, MAX_COINS], + note: Optional[appcmds.Range[str, 1, 1024]] = None): + if not ctx.interaction: + return + t = self.bot.translator.t + + # Validate target + if user.bot: + raise UserInputError( + t(_p( + 'cmd:gift|error:target_bot', + "You cannot gift LionGems to bots!" + )) + ) + + if user.id == ctx.author.id: + raise UserInputError( + t(_p( + 'cmd:gift|error:target_is_author', + "You cannot gift LionGems to yourself!" + )) + ) + + # Prepare and open gift confirmation modal + amount_field = TextInput( + label=t(_p( + 'cmd:gift|modal:confirm|field:amount|label', + "Number of LionGems to Gift" + )), + default=str(amount), + required=True, + ) + note_field = TextInput( + label=t(_p( + 'cmd:gift|modal:confirm|field:note|label', + "Add an optional note to your gift" + )), + default=note or '', + required=False, + max_length=1024, + style=TextStyle.long, + ) + modal = FastModal( + amount_field, note_field, + title=t(_p( + 'cmd:gift|modal:confirm|title', + "Confirm LionGem Gift" + )) + ) + + await ctx.interaction.response.send_modal(modal) + + try: + interaction = await modal.wait_for(timeout=300) + except asyncio.TimeoutError: + # Presume user cancelled and wants to abort + raise SafeCancellation + + await interaction.response.defer(thinking=False) + + # Parse amount + amountstr = amount_field.value + if not amountstr.isdigit(): + raise UserInputError( + t(_p( + 'cmd:gift|error:parse_amount', + "Could not parse `{provided}` as a number!" + )).format(provided=amountstr) + ) + amount = int(amountstr) + + if amount == 0: + raise UserInputError( + t(_p( + 'cmd:gift|error:amount_zero', + "Cannot gift `0` gems." + )) + ) + + # Get author's balance, make sure they have enough + author_balance = await self.get_gem_balance(ctx.author.id) + if author_balance < amount: + raise UserInputError( + t(_p( + 'cmd:gift|error:author_balance_too_low', + "Insufficient balance to send {gem}**{amount}**!\n" + "Current balance: {gem}**{balance}**" + )).format( + gem=self.bot.config.emojis.gem, + amount=amount, + balance=author_balance, + ) + ) + + # Everything seems to be in order, run the transaction + try: + transaction = await self.gem_transaction( + GemTransactionType.GIFT, + actorid=ctx.author.id, + from_account=ctx.author.id, to_account=user.id, + amount=amount, + description="Gift given through command '/gift'", + note=note_field.value or None + ) + except BalanceTooLow: + raise UserInputError( + t(_p( + 'cmd:gift|error:balance_too_low', + "Insufficient Balance to complete gift!" + )) + ) + + # Attempt to send note to user + + thumb = "https://cdn.discordapp.com/attachments/925799205954543636/938704034578194443/C85AF926-9F75-466F-9D8E-D47721427F5D.png" + icon = "https://cdn.discordapp.com/attachments/925799205954543636/938703943683416074/4CF1C849-D532-4DEC-B4C9-0AB11F443BAB.png" + desc = t(_p( + 'cmd:gift|target_msg|desc', + "You were just gifted {gem}**{amount}** by {user}!\n" + "To use them, use the command {skin_cmd} to change your graphics skin!" + )).format( + gem=self.bot.config.emojis.gem, + amount=amount, + user=ctx.author.mention, + skin_cmd=self.bot.core.mention_cmd('my skin'), + ) + embed = discord.Embed( + description=desc, + colour=discord.Colour.orange() + ) + embed.set_thumbnail(url=thumb) + embed.set_author( + name=t(_p('cmd:gift|target_msg|author:name', "LionGems Delivery!")), + icon_url=icon, + ) + embed.set_footer( + text=t(_p( + 'cmd:gift|target_msg|footer:text', + "You now have {balance} LionGems" + )).format( + balance=await self.get_gem_balance(user.id), + ) + ) + embed.timestamp = utc_now() + + note = note_field.value + if note: + embed.add_field( + name=t(_p( + 'cmd:gift|target_msg|field:note|name', + "The sender attached a note" + )), + value=note + ) + + notify_sent = False + try: + await user.send(embed=embed) + notify_sent = True + except discord.HTTPException: + logger.info( + f"Could not send LionGem gift target their gift notification. Transaction {transaction.transactionid}" + ) + + # Finally, send the ack back to the author + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'cmd:gift|embed:success|title', + "Gift Sent!" + )), + description=t(_p( + 'cmd:gift|embed:success|description', + "Your gift of {gem}**{amount}** is on its way to {target}!" + )).format( + gem=self.bot.config.emojis.gem, + amount=amount, + target=user.mention, + ) + ) + embed.set_footer( + text=t(_p( + 'cmd:gift|embed:success|footer', + "New Balance: {balance} LionGems", + )).format(balance=await self.get_gem_balance(ctx.author.id)) + ) + if not notify_sent: + embed.add_field( + name="", + value=t(_p( + 'cmd:gift|embed:success|field:notify_failed|value', + "Unfortunately, I couldn't tell them about it! " + "They might have direct messages with me turned off." + )) + ) + + await ctx.reply(embed=embed, ephemeral=True) + + + @cmds.hybrid_command( + name=_p('cmd:premium', "premium"), + description=_p( + 'cmd:premium|desc', + "Upgrade your server with LionGems!" + ) + ) + async def cmd_premium(self, ctx: LionContext): + # TODO + ... + + # ----- Owner Commands ----- + @LionCog.placeholder_group + @cmds.hybrid_group("leo", with_app_command=False) + async def leo_group(self, ctx: LionContext): + ... + + + @leo_group.command( + name=_p('cmd:leo_gems', "gems"), + description=_p( + 'cmd:leo_gems|desc', + "View and adjust a user's LionGem balance." + ) + ) + @appcmds.rename( + target=_p('cmd:leo_gems|param:target', "target"), + adjustment=_p('cmd:leo_gems|param:adjustment', "adjustment"), + note=_p('cmd:leo_gems|param:note', "note"), + reason=_p('cmd:leo_gems|param:reason', "reason") + ) + @appcmds.describe( + target=_p( + 'cmd:leo_gems|param:target|desc', + "Target user you wish to view or modify LionGems for." + ), + adjustment=_p( + 'cmd:leo_gems|param:adjustment|desc', + "Number of LionGems to add to the target's balance (may be negative to remove)" + ), + note=_p( + 'cmd:leo_gems|param:note|desc', + "Optional note to attach to the delivery message when adding LionGems." + ), + reason=_p( + 'cmd:leo_gems|param:reason|desc', + 'Optional reason or context to add to the gem audit log for this transaction.' + ) + ) + @sys_admin_ward + async def cmd_leo_gems(self, ctx: LionContext, + target: discord.User, + adjustment: Optional[int] = None, + note: Optional[appcmds.Range[str, 0, 1024]] = None, + reason: Optional[appcmds.Range[str, 0, 1024]] = None,): + if not ctx.interaction: + return + t = self.bot.translator.t + + if adjustment is None or adjustment == 0: + # History viewing pathway + ui = TransactionList(self.bot, target.id, callerid=ctx.author.id) + await ui.run(ctx.interaction) + await ui.wait() + else: + # Adjustment path + # Show confirmation modal with note and reason + adjustment_field = TextInput( + label=t(_p( + 'cmd:leo_gems|adjust|modal:confirm|field:amount|label', + "Number of LionGems to add. May be negative." + )), + default=str(adjustment), + required=True, + ) + note_field = TextInput( + label=t(_p( + 'cmd:leo_gems|adjust|modal:confirm|field:note|label', + "Optional note to attach to delivery message." + )), + default=note, + style=TextStyle.long, + max_length=1024, + required=False, + ) + reason_field = TextInput( + label=t(_p( + 'cmd:leo_gems|adjust|modal:confirm|field:reason|label', + "Optional reason to add to the audit log." + )), + default=reason, + style=TextStyle.long, + max_length=1024, + required=False, + ) + + modal = FastModal( + adjustment_field, note_field, reason_field, + title=t(_p( + 'cmd:leo_gems|adjust|modal:confirm|title', + "Confirm LionGem Adjustment" + )) + ) + await ctx.interaction.response.send_modal(modal) + + try: + interaction = await modal.wait_for(timeout=300) + except asyncio.TimeoutError: + raise SafeCancellation + + await interaction.response.defer(thinking=False) + + # Parse values + try: + amount = int(adjustment_field.value) + except ValueError: + raise UserInputError( + t(_p( + 'cmd:leo_gems|adjust|error:parse_adjustment', + "Could not parse `{given}` as an integer." + )).format(given=adjustment_field.value) + ) + note = note_field.value or None + reason = reason_field.value or None + + # Run transaction + try: + transaction = await self.gem_transaction( + GemTransactionType.ADMIN, + actorid=ctx.author.id, + from_account=None, to_account=target.id, + amount=amount, + description=f"Admin balance adjustment with '/leo gems'.\n{reason}", + note=note + ) + except GemTransactionFailed: + raise UserInputError( + t(_p( + 'cmd:leo_gems|adjust|error:unknown', + "Balance adjustment failed! Check logs for more information." + )) + ) + # DM user with note if applicable + if amount > 0: + thumb = "https://cdn.discordapp.com/attachments/925799205954543636/938704034578194443/C85AF926-9F75-466F-9D8E-D47721427F5D.png" + icon = "https://cdn.discordapp.com/attachments/925799205954543636/938703943683416074/4CF1C849-D532-4DEC-B4C9-0AB11F443BAB.png" + desc = t(_p( + 'cmd:leo_gems|adjust|target_msg|desc', + "You were given {gem}**{amount}**!\n" + "To use them, use the command {skin_cmd} to change your graphics skin!" + )).format( + gem=self.bot.config.emojis.gem, + amount=amount, + skin_cmd=self.bot.core.mention_cmd('my skin'), + ) + embed = discord.Embed( + description=desc, + colour=discord.Colour.orange() + ) + embed.set_thumbnail(url=thumb) + embed.set_author( + name=t(_p('cmd:leo_gems|adjust|target_msg|author:name', "LionGems Delivery!")), + icon_url=icon, + ) + embed.set_footer( + text=t(_p( + 'cmd:leo_gems|adjust|target_msg|footer:text', + "You now have {balance} LionGems" + )).format( + balance=await self.get_gem_balance(target.id), + ) + ) + embed.timestamp = utc_now() + + note = note_field.value + if note: + embed.add_field( + name=t(_p( + 'cmd:lion_gems|adjust|target_msg|field:note|name', + "Note" + )), + value=note + ) + + try: + await target.send(embed=embed) + target_notified = True + except discord.HTTPException: + target_notified = False + else: + target_notified = None + + # Ack the operation + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'cmd:lion_gems|adjust|embed:success|title', + "Success" + )), + description=t(_p( + 'cmd:lion_gems|adjust|embed:success|description', + "Added {gem}**{amount}** to {target}'s account.\n" + "They now have {gem}**{balance}**" + )).format( + gem=self.bot.config.emojis.gem, + target=target.mention, + amount=amount, + balance=await self.get_gem_balance(target.id), + ) + ) + if target_notified is False: + embed.add_field( + name="", + value=t(_p( + 'cmd:lion_gems|adjust|embed:success|field:notify_failed|value', + "Could not notify the target, they probably have direct messages disabled." + )) + ) + + await ctx.reply(embed=embed, ephemeral=True) diff --git a/src/modules/premium/data.py b/src/modules/premium/data.py new file mode 100644 index 00000000..0643bee0 --- /dev/null +++ b/src/modules/premium/data.py @@ -0,0 +1,92 @@ +from enum import Enum + +from psycopg import sql +from meta.logger import log_wrap +from data import Registry, RowModel, RegisterEnum, Table +from data.columns import Integer, Bool, Column, Timestamp, String + + +class GemTransactionType(Enum): + """ + Schema + ------ + CREATE TYPE GemTransactionType AS ENUM ( + 'ADMIN', + 'GIFT', + 'PURCHASE', + 'AUTOMATIC' + ); + """ + ADMIN = 'ADMIN', + GIFT = 'GIFT', + PURCHASE = 'PURCHASE', + AUTOMATIC = 'AUTOMATIC', + + +class PremiumData(Registry): + GemTransactionType = RegisterEnum(GemTransactionType, 'GemTransactionType') + + class GemTransaction(RowModel): + """ + Schema + ------ + + CREATE TABLE gem_transactions( + transactionid SERIAL PRIMARY KEY, + transaction_type GemTransactionType NOT NULL, + actorid BIGINT NOT NULL, + from_account BIGINT, + to_account BIGINT, + amount INTEGER NOT NULL, + description TEXT NOT NULL, + note TEXT, + reference TEXT, + _timestamp TIMESTAMPTZ DEFAULT now() + ); + CREATE INDEX gem_transactions_from ON gem_transactions (from_account); + """ + _tablename_ = 'gem_transactions' + + transactionid = Integer(primary=True) + transaction_type: Column[GemTransactionType] = Column() + actorid = Integer() + from_account = Integer() + to_account = Integer() + amount = Integer() + description = String() + note = String() + reference = String() + + _timestamp = Timestamp() + + class PremiumGuild(RowModel): + """ + Schema + ------ + CREATE TABLE premium_guilds( + guildid BIGINT PRIMARY KEY REFERENCES guild_config, + premium_since TIMESTAMPTZ NOT NULL DEFAULT now(), + premium_until TIMESTAMPTZ NOT NULL DEFAULT now(), + custom_skin_id INTEGER REFERENCES customised_skins + ); + """ + _tablename_ = "premium_guilds" + _cache_ = {} + + guildid = Integer(primary=True) + premium_since = Timestamp() + premium_until = Timestamp() + custom_skin_id = Integer() + + """ + CREATE TABLE premium_guild_contributions( + contributionid SERIAL PRIMARY KEY, + userid BIGINT NOT NULL REFERENCES user_config, + guildid BIGINT NOT NULL REFERENCES premium_guilds, + transactionid INTEGER REFERENCES gem_transactions, + duration INTEGER NOT NULL, + _timestamp TIMESTAMPTZ DEFAULT now() + ); + """ + premium_guild_contributions = Table('premium_guild_contributions') + diff --git a/src/modules/premium/errors.py b/src/modules/premium/errors.py new file mode 100644 index 00000000..043817de --- /dev/null +++ b/src/modules/premium/errors.py @@ -0,0 +1,19 @@ +class GemTransactionFailed(Exception): + """ + Base exception class used when a gem transaction failed. + """ + pass + + +class BalanceTooLow(GemTransactionFailed): + """ + Exception raised when transaction results in a negative gem balance. + """ + pass + + +class BalanceTooHigh(GemTransactionFailed): + """ + Exception raised when transaction results in gem balance overflow. + """ + pass diff --git a/src/modules/premium/ui/premium.py b/src/modules/premium/ui/premium.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/premium/ui/transactions.py b/src/modules/premium/ui/transactions.py new file mode 100644 index 00000000..012a9784 --- /dev/null +++ b/src/modules/premium/ui/transactions.py @@ -0,0 +1,198 @@ +from typing import Optional +import asyncio +import datetime as dt + +import discord +from discord.ui.button import button, Button, ButtonStyle + +from meta import LionBot, conf +from data import ORDER + +from utils.ui import MessageUI, input +from utils.lib import MessageArgs, tabulate + +from .. import babel, logger +from ..data import PremiumData + +_p = babel._p + + +class TransactionList(MessageUI): + block_len = 5 + + def __init__(self, bot: LionBot, userid: int, **kwargs): + super().__init__(**kwargs) + + self.bot = bot + self.userid = userid + + self._pagen = 0 + self.blocks: list[list[PremiumData.GemTransaction]] = [[]] + + @property + def page_count(self): + return len(self.blocks) + + @property + def pagen(self): + self._pagen = self._pagen % self.page_count + return self._pagen + + @pagen.setter + def pagen(self, value): + self._pagen = value % self.page_count + + @property + def current_page(self): + return self.blocks[self.pagen] + + # ----- UI Components ----- + + # Backwards + @button(emoji=conf.emojis.backward, style=ButtonStyle.grey) + async def prev_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + self.pagen -= 1 + await self.refresh(thinking=press) + + # Jump to page + @button(label="JUMP_PLACEHOLDER", style=ButtonStyle.blurple) + async def jump_button(self, press: discord.Interaction, pressed: Button): + """ + Jump-to-page button. + Loads a page-switch dialogue. + """ + t = self.bot.translator.t + try: + interaction, value = await input( + press, + title=t(_p( + 'ui:transactions|button:jump|input:title', + "Jump to page" + )), + question=t(_p( + 'ui:transactions|button:jump|input:question', + "Page number to jump to" + )) + ) + value = value.strip() + except asyncio.TimeoutError: + return + + if not value.lstrip('- ').isdigit(): + error_embed = discord.Embed( + title=t(_p( + 'ui:transactions|button:jump|error:invalid_page', + "Invalid page number, please try again!" + )), + colour=discord.Colour.brand_red() + ) + await interaction.response.send_message(embed=error_embed, ephemeral=True) + else: + await interaction.response.defer(thinking=True) + pagen = int(value.lstrip('- ')) + if value.startswith('-'): + pagen = -1 * pagen + elif pagen > 0: + pagen = pagen - 1 + self.pagen = pagen + await self.refresh(thinking=interaction) + + async def jump_button_refresh(self): + component = self.jump_button + component.label = f"{self.pagen + 1}/{self.page_count}" + component.disabled = (self.page_count <= 1) + + # Forward + @button(emoji=conf.emojis.forward, style=ButtonStyle.grey) + async def next_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True) + self.pagen += 1 + await self.refresh(thinking=press) + + # Quit + @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) + async def quit_button(self, press: discord.Interaction, pressed: Button): + """ + Quit the UI. + """ + await press.response.defer() + await self.quit() + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + + title = t(_p( + 'ui:transactions|embed|title', + "Gem Transactions for user `{userid}`" + )).format(userid=self.userid) + + rows = self.current_page + if rows: + embed = discord.Embed( + colour=discord.Colour.orange(), + title=title, + description=t(_p( + 'ui:transactions|embed|desc:balance', + "User {target} has a LionGem balance of {gem}**{balance}**" + )).format( + gem=self.bot.config.emojis.gem, + target=f"<@{self.userid}>", + balance=await (self.bot.get_cog('PremiumCog')).get_gem_balance(self.userid), + ) + ) + for row in rows: + name = f"Transaction #{row.transactionid}" + table_rows = ( + ('timestamp', discord.utils.format_dt(row._timestamp)), + ('type', row.transaction_type.name), + ('amount', str(row.amount)), + ('actor', f"<@{row.actorid}>"), + ('from', f"`{row.from_account}`" if row.from_account else 'None'), + ('to', f"`{row.to_account}`" if row.to_account else 'None'), + ('reference', str(row.reference)), + ) + table = '\n'.join(tabulate(*table_rows)) + embed.add_field( + name=name, + value=f"{row.description}\n{table}", + inline=False + ) + else: + embed = discord.Embed( + colour=discord.Colour.brand_red(), + description = t(_p( + 'ui:transactions|embed|desc:no_transactions', + "This user has no related gem transactions!" + )) + ) + return MessageArgs(embed=embed) + + async def refresh_layout(self): + to_refresh = ( + self.jump_button_refresh(), + ) + await asyncio.gather(*to_refresh) + + if self.page_count > 1: + self.set_layout( + (self.prev_button, self.jump_button, self.quit_button, self.next_button), + ) + else: + self.set_layout( + (self.quit_button,) + ) + + async def reload(self): + model = PremiumData.GemTransaction + + rows = await model.fetch_where( + (model.from_account == self.userid) | (model.to_account == self.userid) + ).order_by('_timestamp', ORDER.DESC) + + blocks = [ + rows[i:i+self.block_len] + for i in range(0, len(rows), self.block_len) + ] + self.blocks = blocks or [[]] From d3bd233b3edf71e342edf891b7fb11cd41a7ac6d Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 26 Oct 2023 13:48:17 +0300 Subject: [PATCH 12/22] feat(premium): Impl guild upgrade path. --- src/modules/premium/cog.py | 18 +- src/modules/premium/ui/premium.py | 286 ++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+), 5 deletions(-) diff --git a/src/modules/premium/cog.py b/src/modules/premium/cog.py index e88a8611..5eb40164 100644 --- a/src/modules/premium/cog.py +++ b/src/modules/premium/cog.py @@ -19,6 +19,7 @@ from constants import MAX_COINS from . import logger, babel from .data import PremiumData, GemTransactionType from .ui.transactions import TransactionList +from .ui.premium import PremiumUI from .errors import GemTransactionFailed, BalanceTooLow, BalanceTooHigh _p = babel._p @@ -44,7 +45,7 @@ class PremiumCog(LionCog): # ----- API ----- - def buy_gems_buttons(self) -> Button: + def buy_gems_button(self) -> Button: t = self.bot.translator.t button = Button( @@ -89,7 +90,7 @@ class PremiumCog(LionCog): row = await self.data.PremiumGuild.fetch(guildid) now = utc_now() - premium = (row is not None) and (row.premium_until > now) + premium = (row is not None) and row.premium_until and (row.premium_until > now) return premium @log_wrap(isolate=True) @@ -216,6 +217,7 @@ class PremiumCog(LionCog): try: await self.gem_logger.send(embed=embed) + posted = True except discord.HTTPException: pass @@ -482,7 +484,6 @@ class PremiumCog(LionCog): await ctx.reply(embed=embed, ephemeral=True) - @cmds.hybrid_command( name=_p('cmd:premium', "premium"), description=_p( @@ -490,9 +491,16 @@ class PremiumCog(LionCog): "Upgrade your server with LionGems!" ) ) + @appcmds.guild_only async def cmd_premium(self, ctx: LionContext): - # TODO - ... + if not ctx.guild: + return + if not ctx.interaction: + return + + ui = PremiumUI(self.bot, ctx.guild, ctx.luser, callerid=ctx.author.id) + await ui.run(ctx.interaction) + await ui.wait() # ----- Owner Commands ----- @LionCog.placeholder_group diff --git a/src/modules/premium/ui/premium.py b/src/modules/premium/ui/premium.py index e69de29b..3a5dd9a3 100644 --- a/src/modules/premium/ui/premium.py +++ b/src/modules/premium/ui/premium.py @@ -0,0 +1,286 @@ +from typing import Optional, TYPE_CHECKING, NamedTuple +import asyncio +import datetime as dt + +import discord +from discord.ui.button import button, Button, ButtonStyle +from psycopg import sql + +from meta import LionBot, conf +from meta.logger import log_wrap +from core.lion_user import LionUser +from babel.translator import LazyStr +from meta.errors import ResponseTimedOut, UserInputError +from data import RawExpr +from modules.premium.errors import BalanceTooLow + +from utils.ui import MessageUI, Confirm, AButton +from utils.lib import MessageArgs, utc_now + +from .. import babel, logger +from ..data import GemTransactionType, PremiumData + +if TYPE_CHECKING: + from ..cog import PremiumCog + +_p = babel._p + + +class PremiumPlan(NamedTuple): + text: LazyStr + label: LazyStr + emoji: Optional[discord.PartialEmoji | discord.Emoji | str] + duration: int + price: int + + +plans = [ + PremiumPlan( + _p('plan:three_months|text', "three months"), + _p('plan:three_months|label', "Three Months"), + None, + 90, + 4000 + ), + PremiumPlan( + _p('plan:one_year|text', "one year"), + _p('plan:one_year|label', "One Year"), + None, + 365, + 12000 + ), + PremiumPlan( + _p('plan:one_month|text', "one month"), + _p('plan:one_month|label', "One Month"), + None, + 30, + 1500 + ), +] + + +class PremiumUI(MessageUI): + def __init__(self, bot: LionBot, guild: discord.Guild, luser: LionUser, **kwargs): + super().__init__(**kwargs) + + self.bot = bot + self.guild = guild + self.luser = luser + + self.cog: 'PremiumCog' = bot.get_cog('PremiumCog') # type: ignore + + # UI State + self.premium_status: Optional[PremiumData.PremiumGuild] = None + + self.plan_buttons = self._plan_buttons() + self.link_button = self.cog.buy_gems_button() + + # ----- API ----- + # ----- UI Components ----- + + async def plan_button(self, press: discord.Interaction, pressed: Button, plan: PremiumPlan): + t = self.bot.translator.t + + # Check Balance + if self.luser.data.gems < plan.price: + raise UserInputError( + t(_p( + 'ui:premium|button:plan|error:insufficient_gems', + "You do not have enough LionGems to purchase this plan!" + )) + ) + + # Confirm Purchase + confirm_msg = t(_p( + 'ui:premium|button:plan|confirm|desc', + "Contributing **{plan_text}** of premium subscription for this server" + " will cost you {gem}**{plan_price}**.\n" + "Are you sure you want to proceed?" + )).format( + plan_text=t(plan.text), + gem=self.bot.config.emojis.gem, + plan_price=plan.price, + ) + confirm = Confirm(confirm_msg, press.user.id) + confirm.embed.title = t(_p( + 'ui:premium|button:plan|confirm|title', + "Confirm Server Upgrade" + )) + confirm.embed.set_footer( + text=t(_p( + 'ui:premium|button:plan|confirm|footer', + "Your current balance is {balance} LionGems" + )).format(balance=self.luser.data.gems) + ) + confirm.embed.colour = 0x41f097 + + try: + result = await confirm.ask(press, ephemeral=True) + except ResponseTimedOut: + result = False + if not result: + await press.followup.send( + t(_p( + 'ui:premium|button:plan|confirm|cancelled', + "Purchase cancelled! No LionGems were deducted from your account." + )), + ephemeral=True + ) + return + + # Write transaction, plan contribution, and new plan status, with potential rollback + try: + await self._do_premium_upgrade(plan) + except BalanceTooLow: + raise UserInputError( + t(_p( + 'ui:premium|button:plan|error:insufficient_gems_post_confirm', + "Insufficient LionGems to purchase this plan!" + )) + ) + + # Acknowledge premium + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'ui:premium|button:plan|embed:success|title', + "Server Upgraded!" + )), + description=t(_p( + 'ui:premium|button:plan|embed:success|desc', + "You have contributed **{plan_text}** of premium subscription to this server!" + )).format(plan_text=plan.text) + ) + await press.followup.send( + embed=embed + ) + await self.refresh() + + @log_wrap(action='premium upgrade') + async def _do_premium_upgrade(self, plan: PremiumPlan): + async with self.bot.db.connection() as conn: + self.bot.db.conn = conn + async with conn.transaction(): + # Perform the gem transaction + transaction = await self.cog.gem_transaction( + GemTransactionType.PURCHASE, + actorid=self.luser.userid, + from_account=self.luser.userid, + to_account=None, + amount=plan.price, + description=( + f"User purchased {plan.duration} days of premium" + f" for guild {self.guild.id} using the `PremiumUI`." + ), + note=None, + reference=f"iid: {self._original.id if self._original else 'None'}" + ) + + model = self.cog.data.PremiumGuild + # Ensure the premium guild row exists + premium_guild = await model.fetch_or_create(self.guild.id) + + # Extend the subscription + await model.table.update_where(guildid=self.guild.id).set( + premium_until=RawExpr( + sql.SQL("GREATEST(premium_until, now()) + {}").format( + sql.Placeholder() + ), + (dt.timedelta(days=plan.duration),) + ) + ) + + # Finally, record the user's contribution + await self.cog.data.premium_guild_contributions.insert( + userid=self.luser.userid, guildid=self.guild.id, + transactionid=transaction.transactionid, duration=plan.duration + ) + + def _plan_buttons(self) -> list[Button]: + """ + Generate the Plan buttons. + + Intended to be used once, upon initialisation. + """ + t = self.bot.translator.t + buttons = [] + for plan in plans: + butt = AButton( + label=t(plan.label), + emoji=plan.emoji, + style=ButtonStyle.blurple, + pass_kwargs={'plan': plan} + ) + butt(self.plan_button) + self.add_item(butt) + buttons.append(butt) + return buttons + + # ----- UI Flow ----- + def _current_status(self) -> str: + t = self.bot.translator.t + + if self.premium_status is None or self.premium_status.premium_until is None: + status = t(_p( + 'ui:premium|current_status:none', + "__**Current Server Status:**__ Awaiting Upgrade." + )) + elif self.premium_status.premium_until > utc_now(): + status = t(_p( + 'ui:premium|current_status:premium', + "__**Current Server Status:**__ Upgraded! Premium until {expiry}" + )).format(expiry=discord.utils.format_dt(self.premium_status.premium_until, 'd')) + else: + status = t(_p( + 'ui:premium|current_status:none', + "__**Current Server Status:**__ Awaiting Upgrade. Premium status expired on {expiry}" + )).format(expiry=discord.utils.format_dt(self.premium_status.premium_until, 'd')) + + return status + + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + + blurb = t(_p( + 'ui:premium|embed|description', + "By supporting our project, you will get access to countless customisation features!\n\n" + "- **Rebranding:** Customizable HEX colours and" + " **beautiful premium skins** for all of your community members!\n" + "- **Remove the vote and sponsor prompt!**\n" + "- Access to all of the [future premium features](https://staging.lionbot.org/donate)\n\n" + "Both server owners and **regular users** can" + " **buy and gift a subscription for this server** using this command!\n" + "To support both Leo and your server, **use the buttons below**!" + )) + '\n\n' + self._current_status() + + embed = discord.Embed( + colour=0x41f097, + title=t(_p( + 'ui:premium|embed|title', + "Support Leo and Upgrade your Server!" + )), + description=blurb, + ) + embed.set_thumbnail( + url="https://i.imgur.com/v1mZolL.png" + ) + embed.set_image( + url="https://cdn.discordapp.com/attachments/824196406482305034/972405513570615326/premium_test.png" + ) + embed.set_footer( + text=t(_p( + 'ui:premium|embed|footer', + "Your current balance is {balance} LionGems." + )).format(balance=self.luser.data.gems) + ) + + return MessageArgs(embed=embed) + + async def refresh_layout(self): + self.set_layout( + (*self.plan_buttons, self.link_button), + ) + + async def reload(self): + self.premium_status = await self.cog.data.PremiumGuild.fetch(self.guild.id, cached=False) + await self.luser.data.refresh() From 73aad8a721228cecc1ff5c42e4548f55ecafe997 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 26 Oct 2023 13:49:03 +0300 Subject: [PATCH 13/22] feat(premium): Ad-whitelist premium guilds. --- src/modules/sponsors/cog.py | 10 +++++++--- src/modules/statistics/ui/leaderboard.py | 5 +++-- src/modules/statistics/ui/profile.py | 4 +++- src/modules/statistics/ui/weeklymonthly.py | 4 +++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/modules/sponsors/cog.py b/src/modules/sponsors/cog.py index 8362dc36..267c7d63 100644 --- a/src/modules/sponsors/cog.py +++ b/src/modules/sponsors/cog.py @@ -36,9 +36,13 @@ class SponsorCog(LionCog): """ if not interaction.is_expired(): # TODO: caching - whitelist = (await self.settings.Whitelist.get(self.bot.appname)).value - if interaction.guild and interaction.guild.id in whitelist: - return + if interaction.guild: + whitelist = (await self.settings.Whitelist.get(self.bot.appname)).value + if interaction.guild.id in whitelist: + return + premiumcog = self.bot.get_cog('PremiumCog') + if premiumcog and await premiumcog.is_premium_guild(interaction.guild.id): + return setting = await self.settings.SponsorPrompt.get(self.bot.appname) value = setting.value if value: diff --git a/src/modules/statistics/ui/leaderboard.py b/src/modules/statistics/ui/leaderboard.py index f21a88a6..48c27005 100644 --- a/src/modules/statistics/ui/leaderboard.py +++ b/src/modules/statistics/ui/leaderboard.py @@ -537,10 +537,11 @@ class LeaderboardUI(StatsUI): period_row, page_row ] - voting = self.bot.get_cog('TopggCog') if voting and not await voting.check_voted_recently(self.userid): - self._layout.append((voting.vote_button(),)) + premiumcog = self.bot.get_cog('PremiumCog') + if not (premiumcog and await premiumcog.is_premium_guild(self.guild.id)): + self._layout.append((voting.vote_button(),)) async def reload(self): """ diff --git a/src/modules/statistics/ui/profile.py b/src/modules/statistics/ui/profile.py index e59fb8cd..c9c39479 100644 --- a/src/modules/statistics/ui/profile.py +++ b/src/modules/statistics/ui/profile.py @@ -299,7 +299,9 @@ class ProfileUI(StatsUI): voting = self.bot.get_cog('TopggCog') if voting and not await voting.check_voted_recently(self.userid): - self._layout.append((voting.vote_button(),)) + premiumcog = self.bot.get_cog('PremiumCog') + if not (premiumcog and await premiumcog.is_premium_guild(self.guild.id)): + self._layout.append((voting.vote_button(),)) async def _render_stats(self): """ diff --git a/src/modules/statistics/ui/weeklymonthly.py b/src/modules/statistics/ui/weeklymonthly.py index eb4a3d16..960bf119 100644 --- a/src/modules/statistics/ui/weeklymonthly.py +++ b/src/modules/statistics/ui/weeklymonthly.py @@ -753,7 +753,9 @@ class WeeklyMonthlyUI(StatsUI): voting = self.bot.get_cog('TopggCog') if voting and not await voting.check_voted_recently(self.userid): - self._layout.append((voting.vote_button(),)) + premiumcog = self.bot.get_cog('PremiumCog') + if not (premiumcog and await premiumcog.is_premium_guild(self.guild.id)): + self._layout.append((voting.vote_button(),)) if self._showing_selector: await self.period_menu_refresh() From bbd00267fae13dd6e948e2e70c04546ceeae8079 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 27 Oct 2023 04:44:47 +0300 Subject: [PATCH 14/22] feat(bot): Impl global dispatch over shardtalk. --- src/core/cog.py | 7 +++++++ src/meta/LionBot.py | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/src/core/cog.py b/src/core/cog.py index d78f6dde..1f36ffb3 100644 --- a/src/core/cog.py +++ b/src/core/cog.py @@ -1,3 +1,4 @@ +import logging from typing import Optional from collections import defaultdict from weakref import WeakValueDictionary @@ -19,6 +20,8 @@ from .lion_member import MemberConfig from .lion_user import UserConfig from .hooks import HookedChannel +logger = logging.getLogger(__name__) + class keydefaultdict(defaultdict): def __missing__(self, key): @@ -127,3 +130,7 @@ class CoreCog(LionCog): @log_wrap(action='Update shard guilds') async def shard_update_guilds(self, guild): await self.shard_data.update(guild_count=len(self.bot.guilds)) + + @LionCog.listener('on_ping') + async def handle_ping(self, *args, **kwargs): + logger.info(f"Received ping with args {args}, kwargs {kwargs}") diff --git a/src/meta/LionBot.py b/src/meta/LionBot.py index 7ee77493..bb48bcf9 100644 --- a/src/meta/LionBot.py +++ b/src/meta/LionBot.py @@ -56,6 +56,14 @@ class LionBot(Bot): self._locks = WeakValueDictionary() self._running_events = set() + self._talk_global_dispatch = app_ipc.register_route('dispatch')(self._handle_global_dispatch) + + async def _handle_global_dispatch(self, event_name: str, *args, **kwargs): + self.dispatch(event_name, *args, **kwargs) + + async def global_dispatch(self, event_name: str, *args, **kwargs): + await self._talk_global_dispatch(event_name, *args, **kwargs).broadcast(except_self=False) + async def _monitor_status(self): if self.is_closed(): level = StatusLevel.ERRORED From e3e717b948074b0bca9629f936a1e199b86ed94b Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 27 Oct 2023 11:20:05 +0300 Subject: [PATCH 15/22] (core): Add explicit cog type overloads. --- src/core/cog.py | 1 - src/meta/LionBot.py | 138 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/core/cog.py b/src/core/cog.py index 1f36ffb3..dde79881 100644 --- a/src/core/cog.py +++ b/src/core/cog.py @@ -74,7 +74,6 @@ class CoreCog(LionCog): self.bot.add_listener(self.shard_update_guilds, name='on_guild_join') self.bot.add_listener(self.shard_update_guilds, name='on_guild_remove') - self.bot.core = self await self.bot.add_cog(self.lions) # Load the app command cache diff --git a/src/meta/LionBot.py b/src/meta/LionBot.py index bb48bcf9..9d514c67 100644 --- a/src/meta/LionBot.py +++ b/src/meta/LionBot.py @@ -1,4 +1,4 @@ -from typing import List, Optional, TYPE_CHECKING +from typing import List, Literal, LiteralString, Optional, TYPE_CHECKING, overload import logging import asyncio from weakref import WeakValueDictionary @@ -24,7 +24,30 @@ from .errors import HandledException, SafeCancellation from .monitor import SystemMonitor, ComponentMonitor, StatusLevel, ComponentStatus if TYPE_CHECKING: - from core import CoreCog + from core.cog import CoreCog + from core.config import ConfigCog + from tracking.voice.cog import VoiceTrackerCog + from tracking.text.cog import TextTrackerCog + from modules.config.cog import GuildConfigCog + from modules.economy.cog import Economy + from modules.member_admin.cog import MemberAdminCog + from modules.meta.cog import MetaCog + from modules.moderation.cog import ModerationCog + from modules.pomodoro.cog import TimerCog + from modules.premium.cog import PremiumCog + from modules.ranks.cog import RankCog + from modules.reminders.cog import Reminders + from modules.rooms.cog import RoomCog + from modules.schedule.cog import ScheduleCog + from modules.shop.cog import ShopCog + from modules.skins.cog import CustomSkinCog + from modules.sponsors.cog import SponsorCog + from modules.statistics.cog import StatsCog + from modules.sysadmin.dash import LeoSettings + from modules.tasklist.cog import TasklistCog + from modules.topgg.cog import TopggCog + from modules.user_config.cog import UserConfigCog + from modules.video_channels.cog import VideoCog logger = logging.getLogger(__name__) @@ -46,7 +69,6 @@ class LionBot(Bot): # self.appdata = appdata self.config = config self.app_ipc = app_ipc - self.core: 'CoreCog' = None self.translator = translator self.system_monitor = SystemMonitor() @@ -58,6 +80,10 @@ class LionBot(Bot): self._talk_global_dispatch = app_ipc.register_route('dispatch')(self._handle_global_dispatch) + @property + def core(self): + return self.get_cog('CoreCog') + async def _handle_global_dispatch(self, event_name: str, *args, **kwargs): self.dispatch(event_name, *args, **kwargs) @@ -107,6 +133,112 @@ class LionBot(Bot): self.tree.copy_global_to(guild=guild) await self.tree.sync(guild=guild) + # To make the type checker happy about fetching cogs by name + # TODO: Move this to stubs at some point + + @overload + def get_cog(self, name: Literal['CoreCog']) -> 'CoreCog': + ... + + @overload + def get_cog(self, name: Literal['ConfigCog']) -> 'ConfigCog': + ... + + @overload + def get_cog(self, name: Literal['VoiceTrackerCog']) -> 'VoiceTrackerCog': + ... + + @overload + def get_cog(self, name: Literal['TextTrackerCog']) -> 'TextTrackerCog': + ... + + @overload + def get_cog(self, name: Literal['GuildConfigCog']) -> 'GuildConfigCog': + ... + + @overload + def get_cog(self, name: Literal['Economy']) -> 'Economy': + ... + + @overload + def get_cog(self, name: Literal['MemberAdminCog']) -> 'MemberAdminCog': + ... + + @overload + def get_cog(self, name: Literal['MetaCog']) -> 'MetaCog': + ... + + @overload + def get_cog(self, name: Literal['ModerationCog']) -> 'ModerationCog': + ... + + @overload + def get_cog(self, name: Literal['TimerCog']) -> 'TimerCog': + ... + + @overload + def get_cog(self, name: Literal['PremiumCog']) -> 'PremiumCog': + ... + + @overload + def get_cog(self, name: Literal['RankCog']) -> 'RankCog': + ... + + @overload + def get_cog(self, name: Literal['Reminders']) -> 'Reminders': + ... + + @overload + def get_cog(self, name: Literal['RoomCog']) -> 'RoomCog': + ... + + @overload + def get_cog(self, name: Literal['ScheduleCog']) -> 'ScheduleCog': + ... + + @overload + def get_cog(self, name: Literal['ShopCog']) -> 'ShopCog': + ... + + @overload + def get_cog(self, name: Literal['CustomSkinCog']) -> 'CustomSkinCog': + ... + + @overload + def get_cog(self, name: Literal['SponsorCog']) -> 'SponsorCog': + ... + + @overload + def get_cog(self, name: Literal['StatsCog']) -> 'StatsCog': + ... + + @overload + def get_cog(self, name: Literal['LeoSettings']) -> 'LeoSettings': + ... + + @overload + def get_cog(self, name: Literal['TasklistCog']) -> 'TasklistCog': + ... + + @overload + def get_cog(self, name: Literal['TopggCog']) -> 'TopggCog': + ... + + @overload + def get_cog(self, name: Literal['UserConfigCog']) -> 'UserConfigCog': + ... + + @overload + def get_cog(self, name: Literal['VideoCog']) -> 'VideoCog': + ... + + @overload + def get_cog(self, name: str) -> Optional[Cog]: + ... + + def get_cog(self, name: str) -> Optional[Cog]: + return super().get_cog(name) + async def add_cog(self, cog: Cog, **kwargs): sup = super() @log_wrap(action=f"Attach {cog.__cog_name__}") From 116bb869dbe0f47bafc09f870e9942615583ff75 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 27 Oct 2023 15:41:26 +0300 Subject: [PATCH 16/22] fix(schema): Fix timer column typo. --- data/schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/schema.sql b/data/schema.sql index 279550dd..dbd057fc 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -1218,7 +1218,7 @@ create TABLE timers( inactivity_threshold INTEGER, channel_name TEXT, pretty_name TEXT, - owenrid BIGINT REFERENCES user_config, + ownerid BIGINT REFERENCES user_config, manager_roleid BIGINT, last_messageid BIGINT, voice_alerts BOOLEAN, From b47744e21994d242315902c7388f315c0c5aa7f9 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 27 Oct 2023 15:42:26 +0300 Subject: [PATCH 17/22] feat(skins): Implement custom skin backend. --- src/modules/__init__.py | 1 + src/modules/skins/__init__.py | 10 + src/modules/skins/cog.py | 340 ++++++++++++++++++ src/modules/skins/data.py | 117 ++++++ src/modules/skins/editor/__init__.py | 0 src/modules/skins/editor/layout.py | 0 src/modules/skins/editor/pages/__init__.py | 0 src/modules/skins/editor/pages/leaderboard.py | 0 src/modules/skins/editor/pages/monthly.py | 0 .../skins/editor/pages/monthly_goals.py | 0 src/modules/skins/editor/pages/profile.py | 0 src/modules/skins/editor/pages/stats.py | 0 src/modules/skins/editor/pages/summary.py | 0 src/modules/skins/editor/pages/weekly.py | 0 .../skins/editor/pages/weekly_goals.py | 0 src/modules/skins/editor/skineditor.py | 0 src/modules/skins/editor/skinsetting.py | 0 src/modules/skins/settings.py | 54 +++ src/modules/skins/settingui.py | 100 ++++++ src/modules/skins/skinlib.py | 175 +++++++++ src/modules/skins/userskinui.py | 0 21 files changed, 797 insertions(+) create mode 100644 src/modules/skins/__init__.py create mode 100644 src/modules/skins/cog.py create mode 100644 src/modules/skins/data.py create mode 100644 src/modules/skins/editor/__init__.py create mode 100644 src/modules/skins/editor/layout.py create mode 100644 src/modules/skins/editor/pages/__init__.py create mode 100644 src/modules/skins/editor/pages/leaderboard.py create mode 100644 src/modules/skins/editor/pages/monthly.py create mode 100644 src/modules/skins/editor/pages/monthly_goals.py create mode 100644 src/modules/skins/editor/pages/profile.py create mode 100644 src/modules/skins/editor/pages/stats.py create mode 100644 src/modules/skins/editor/pages/summary.py create mode 100644 src/modules/skins/editor/pages/weekly.py create mode 100644 src/modules/skins/editor/pages/weekly_goals.py create mode 100644 src/modules/skins/editor/skineditor.py create mode 100644 src/modules/skins/editor/skinsetting.py create mode 100644 src/modules/skins/settings.py create mode 100644 src/modules/skins/settingui.py create mode 100644 src/modules/skins/skinlib.py create mode 100644 src/modules/skins/userskinui.py diff --git a/src/modules/__init__.py b/src/modules/__init__.py index 74964ff5..37deba59 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -4,6 +4,7 @@ active = [ '.sysadmin', '.config', '.user_config', + '.skins', '.schedule', '.economy', '.ranks', diff --git a/src/modules/skins/__init__.py b/src/modules/skins/__init__.py new file mode 100644 index 00000000..93f32c19 --- /dev/null +++ b/src/modules/skins/__init__.py @@ -0,0 +1,10 @@ +import logging +from babel.translator import LocalBabel + +babel = LocalBabel('customskins') +logger = logging.getLogger(__name__) + + +async def setup(bot): + from .cog import CustomSkinCog + await bot.add_cog(CustomSkinCog(bot)) diff --git a/src/modules/skins/cog.py b/src/modules/skins/cog.py new file mode 100644 index 00000000..49d3a003 --- /dev/null +++ b/src/modules/skins/cog.py @@ -0,0 +1,340 @@ +from typing import Optional +import asyncio + +import discord +from discord.ext import commands as cmds +import discord.app_commands as appcmds +from cachetools import LRUCache +from bidict import bidict +from frozendict import frozendict + + +from meta import LionCog, LionBot, LionContext +from meta.logger import log_wrap +from utils.lib import MISSING, utc_now +from wards import sys_admin_ward, low_management_ward +from gui.base import AppSkin +from babel.translator import ctx_locale + +from . import logger, babel +from .data import CustomSkinData +from .skinlib import appskin_as_choice, FrozenCustomSkin, CustomSkin +from .settings import GlobalSkinSettings +from .settingui import GlobalSkinSettingUI + +_p = babel._p + + +class CustomSkinCog(LionCog): + def __init__(self, bot: LionBot): + self.bot = bot + self.data: CustomSkinData = bot.db.load_registry(CustomSkinData()) + self.bot_settings = GlobalSkinSettings() + + # Cache of app skin id -> app skin name + # After initialisation, contains all the base skins available for this app + self.appskin_names: bidict[int, str] = bidict() + + # TODO: Move this to a bijection + # Bijective cache of skin property ids <-> (card_id, property_name) tuples + self.skin_properties: bidict[int, tuple[str, str]] = bidict() + + # Cache of currently active user skins + # Invalidation handled by local event handler + self.active_user_skinids: LRUCache[int, Optional[int]] = LRUCache(maxsize=5000) + + # Cache of custom skin id -> frozen custom skin + self.custom_skins: LRUCache[int, FrozenCustomSkin] = LRUCache(maxsize=1000) + + async def cog_load(self): + await self.data.init() + + if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None: + leo_setting_cog.bot_setting_groups.append(self.bot_settings) + self.crossload_group(self.leo_group, leo_setting_cog.leo_group) + + if (config_cog := self.bot.get_cog('ConfigCog')) is not None: + self.crossload_group(self.admin_group, config_cog.admin_group) + + if (user_cog := self.bot.get_cog('UserConfigCog')) is not None: + self.crossload_group(self.my_group, user_cog.userconfig_group) + + await self._reload_appskins() + await self._reload_property_map() + + async def _reload_property_map(self): + """ + Reload the skin property id to (card_id, property_name) bijection. + """ + records = await self.data.skin_property_map.select_where() + cache = self.skin_properties + + cache.clear() + for record in records: + cache[record['property_id']] = (record['card_id'], record['property_name']) + + logger.info( + f"Loaded '{len(cache)}' custom skin properties." + ) + + async def _reload_appskins(self): + """ + Reload the global_available_skin id to the appskin name. + Create global_available_skins that don't already exist. + """ + cache = self.appskin_names + available = list(AppSkin.skins_data['skin_map'].keys()) + rows = await self.data.GlobalSkin.fetch_where(skin_name=available) + + cache.clear() + for row in rows: + cache[row.skin_id] = row.skin_name + + # Not caring about efficiency here because this essentially needs to happen once ever + missing = [name for name in available if name not in cache.values()] + for name in missing: + row = await self.data.GlobalSkin.create(skin_name=name) + cache[row.skin_id] = row.skin_name + + logger.info( + f"Loaded '{len(cache)}' global base skins." + ) + + # ----- Internal API ----- + def get_base(self, base_skin_id: int) -> AppSkin: + """ + Initialise a localised AppSkin for the given base skin id. + """ + if base_skin_id not in self.appskin_names: + raise ValueError(f"Unknown app skin id '{base_skin_id}'") + + return AppSkin.get( + skin_id=self.appskin_names[base_skin_id], + locale=ctx_locale.get(), + use_cache=True, + ) + + async def get_default_skin(self) -> Optional[str]: + """ + Get the current app-default skin, and return it as a skin name. + + May be None if there is no app-default set. + This should almost always hit cache. + """ + setting = self.bot_settings.DefaultSkin + instance = await setting.get(self.bot.appname) + return instance.value + + async def fetch_property_ids(self, *card_properties: tuple[str, str]) -> list[int]: + """ + Fetch the skin property ids for the given (card_id, property_name) tuples. + + Creates any missing properties. + """ + mapper = self.skin_properties.inverse + missing = [prop for prop in card_properties if prop not in mapper] + if missing: + # First insert missing properties + await self.data.skin_property_map.insert_many( + ('card_id', 'property_name'), + *missing + ) + await self._reload_property_map() + return [mapper[prop] for prop in card_properties] + + async def get_guild_skinid(self, guildid: int) -> Optional[int]: + """ + Fetch the custom_skin_id associated to the current guild. + + Returns None if the guild is not premium or has no custom skin set. + Usually hits cache (Specifically the PremiumGuild cache). + """ + cog = self.bot.get_cog('PremiumCog') + if not cog: + logger.error( + "Trying to get guild skinid without loaded premium cog!" + ) + return None + row = await cog.data.PremiumGuild.fetch(guildid) + return row.custom_skin_id if row else None + + async def get_user_skinid(self, userid: int) -> Optional[int]: + """ + Fetch the custom_skin_id of the active skin in the given user's skin inventory. + + Returns None if the user does not have an active skin. + Should usually be cached by `self.active_user_skinids`. + """ + skinid = self.active_user_skinids.get(userid, MISSING) + if skinid is MISSING: + rows = await self.data.UserSkin.fetch_where(userid=userid, active=True) + skinid = rows[0].custom_skin_id if rows else None + self.active_user_skinids[userid] = skinid + return skinid + + async def args_for_skin(self, skinid: int, cardid: str) -> dict[str, str]: + """ + Fetch the skin argument dictionary for the given custom_skin_id. + + Should usually be cached by `self.custom_skin_args`. + """ + skin = self.custom_skins.get(skinid, None) + if skin is None: + custom_skin = await CustomSkin.fetch(self.bot, skinid) + skin = custom_skin.freeze() + self.custom_skins[skinid] = skin + return skin.args_for(cardid) + + # ----- External API ----- + async def get_skinargs_for(self, + guildid: Optional[int], userid: Optional[int], card_id: str + ) -> dict[str, str]: + """ + Get skin arguments for a standard GUI render with the given guild, user, and for the given card. + + Takes into account the global defaults, guild custom skin, and user active skin. + """ + args = {} + + if userid and (skinid := await self.get_user_skinid(userid)): + skin_args = await self.args_for_skin(skinid, card_id) + args.update(skin_args) + elif guildid and (skinid := await self.get_guild_skinid(guildid)): + skin_args = await self.args_for_skin(skinid, card_id) + args.update(skin_args) + + default = await self.get_default_skin() + if default: + args.setdefault("base_skin_id", default) + + return args + + # ----- Event Handlers ----- + @LionCog.listener('on_userset_skin') + async def refresh_user_skin(self, userid: int): + """ + Update cached user active skinid. + """ + self.active_user_skinids.pop(userid, None) + await self.get_user_skinid(userid) + + @LionCog.listener('on_skin_updated') + async def refresh_custom_skin(self, skinid: int): + """ + Update cached args for given custom skin id. + """ + self.custom_skins.pop(skinid, None) + custom_skin = await CustomSkin.fetch(skinid) + if custom_skin is not None: + skin = custom_skin.freeze() + self.custom_skins[skinid] = skin + + # ----- Userspace commands ----- + @LionCog.placeholder_group + @cmds.hybrid_group("my", with_app_command=False) + async def my_group(self, ctx: LionContext): + ... + + @my_group.command( + name=_p('cmd:my_skin', "skin"), + description=_p( + 'cmd:my_skin|desc', + "Change the colours of your interface" + ) + ) + async def cmd_my_skin(self, ctx: LionContext): + if not ctx.interaction: + return + # TODO + ... + + # ----- Adminspace commands ----- + @LionCog.placeholder_group + @cmds.hybrid_group("admin", with_app_command=False) + async def admin_group(self, ctx: LionContext): + ... + + @admin_group.command( + name=_p('cmd:admin_brand', "brand"), + description=_p( + 'cmd:admin_brand|desc', + "Fully customise my default interface for your members!" + ) + ) + @low_management_ward + async def cmd_admin_brand(self, ctx: LionContext): + if not ctx.interaction: + return + if not ctx.guild: + return + # TODO + ... + + # ----- Owner commands ----- + @LionCog.placeholder_group + @cmds.hybrid_group("leo", with_app_command=False) + async def leo_group(self, ctx: LionContext): + ... + + @leo_group.command( + name=_p('cmd:leo_skin', "skin"), + description=_p( + 'cmd:leo_skin|desc', + "View and update the global skin settings" + ) + ) + @appcmds.rename( + default_skin=_p('cmd:leo_skin|param:default_skin', "default_skin"), + ) + @appcmds.describe( + default_skin=_p( + 'cmd:leo_skin|param:default_skin|desc', + "Set the global default skin." + ) + ) + @sys_admin_ward + async def cmd_leo_skin(self, ctx: LionContext, + default_skin: Optional[str] = None): + if not ctx.interaction: + return + + await ctx.interaction.response.defer(thinking=True) + modified = [] + + if default_skin is not None: + setting = self.bot_settings.DefaultSkin + instance = await setting.from_string(self.bot.appname, default_skin) + modified.append(instance) + + for instance in modified: + await instance.write() + + # No update_str, just show the config window + ui = GlobalSkinSettingUI(self.bot, self.bot.appname, ctx.channel.id) + await ui.run(ctx.interaction) + await ui.wait() + + + @cmd_leo_skin.autocomplete('default_skin') + async def cmd_leo_skin_acmpl_default_skin(self, interaction: discord.Interaction, partial: str): + babel = self.bot.get_cog('BabelCog') + ctx_locale.set(await babel.get_user_locale(interaction.user.id)) + + choices = [] + for skinid in self.appskin_names: + appskin = self.get_base(skinid) + match = partial.lower() + if match in appskin.skin_id.lower() or match in appskin.display_name.lower(): + choices.append(appskin_as_choice(appskin)) + if not choices: + t = self.bot.translator.t + choices = [ + appcmds.Choice( + name=t(_p( + 'cmd:leo_skin|acmpl:default_skin|error:no_match', + "No app skins matching {partial}" + )).format(partial=partial)[:100], + value=partial + ) + ] + return choices diff --git a/src/modules/skins/data.py b/src/modules/skins/data.py new file mode 100644 index 00000000..742310a6 --- /dev/null +++ b/src/modules/skins/data.py @@ -0,0 +1,117 @@ +from data import Registry, RowModel, Table +from data.columns import Integer, Bool, Timestamp, String + + +class CustomSkinData(Registry): + class GlobalSkin(RowModel): + """ + Schema + ------ + CREATE TABLE global_available_skins( + skin_id SERIAL PRIMARY KEY, + skin_name TEXT NOT NULL + ); + CREATE INDEX global_available_skin_names ON global_available_skins (skin_name); + """ + _tablename_ = 'global_available_skins' + _cache_ = {} + + skin_id = Integer(primary=True) + skin_name = String() + + class CustomisedSkin(RowModel): + """ + Schema + ------ + CREATE TABLE customised_skins( + custom_skin_id SERIAL PRIMARY KEY, + base_skin_id INTEGER REFERENCES global_available_skins (skin_id), + _timestamp TIMESTAMPTZ DEFAULT now() + ); + """ + _tablename_ = 'customised_skins' + + custom_skin_id = Integer(primary=True) + base_skin_id = Integer() + + _timestamp = Timestamp() + + """ + Schema + ------ + CREATE TABLE customised_skin_property_ids( + property_id SERIAL PRIMARY KEY, + card_id TEXT NOT NULL, + property_name TEXT NOT NULL, + UNIQUE(card_id, property_name) + ); + """ + skin_property_map = Table('customised_skin_property_ids') + + """ + Schema + ------ + CREATE TABLE customised_skin_properties( + custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id), + property_id INTEGER NOT NULL REFERENCES customised_skin_property_ids (property_id), + value TEXT NOT NULL, + PRIMARY KEY (custom_skin_id, property_id) + ); + CREATE INDEX customised_skin_property_skin_id ON customised_skin_properties(custom_skin_id); + """ + skin_properties = Table('customised_skin_properties') + + """ + Schema + ------ + CREATE VIEW customised_skin_data AS + SELECT + skins.custom_skin_id AS custom_skin_id, + skins.base_skin_id AS base_skin_id, + properties.property_id AS property_id, + prop_ids.card_id AS card_id, + prop_ids.property_name AS property_name, + properties.value AS value + FROM + customised_skins skins + LEFT JOIN customised_skin_properties properties ON skins.custom_skin_id = properties.custom_skin_id + LEFT JOIN customised_skin_property_ids prop_ids ON properties.property_id = prop_ids.property_id; + """ + custom_skin_info = Table('customised_skin_data') + + class UserSkin(RowModel): + """ + Schema + ------ + CREATE TABLE user_skin_inventory( + itemid SERIAL PRIMARY KEY, + userid BIGINT NOT NULL REFERENCES user_config (userid) ON DELETE CASCADE, + custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id) ON DELETE CASCADE, + transactionid INTEGER REFERENCES gem_transactions (transactionid), + active BOOLEAN NOT NULL DEFAULT FALSE, + acquired_at TIMESTAMPTZ DEFAULT now(), + expires_at TIMESTAMPTZ + ); + CREATE INDEX user_skin_inventory_users ON user_skin_inventory(userid); + CREATE UNIQUE INDEX user_skin_inventory_active ON user_skin_inventory(userid) WHERE active = TRUE; + """ + _tablename_ = 'user_skin_inventory' + + itemid = Integer(primary=True) + userid = Integer() + custom_skin_id = Integer() + transactionid = Integer() + active = Bool() + acquired_at = Timestamp() + expires_at = Timestamp() + + """ + Schema + ------ + CREATE VIEW user_active_skins AS + SELECT + * + FROM user_skin_inventory + WHERE active=True; + """ + user_active_skins = Table('user_active_skins') diff --git a/src/modules/skins/editor/__init__.py b/src/modules/skins/editor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/layout.py b/src/modules/skins/editor/layout.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/__init__.py b/src/modules/skins/editor/pages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/leaderboard.py b/src/modules/skins/editor/pages/leaderboard.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/monthly.py b/src/modules/skins/editor/pages/monthly.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/monthly_goals.py b/src/modules/skins/editor/pages/monthly_goals.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/profile.py b/src/modules/skins/editor/pages/profile.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/stats.py b/src/modules/skins/editor/pages/stats.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/summary.py b/src/modules/skins/editor/pages/summary.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/weekly.py b/src/modules/skins/editor/pages/weekly.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/weekly_goals.py b/src/modules/skins/editor/pages/weekly_goals.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/skineditor.py b/src/modules/skins/editor/skineditor.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/skinsetting.py b/src/modules/skins/editor/skinsetting.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/settings.py b/src/modules/skins/settings.py new file mode 100644 index 00000000..2c248942 --- /dev/null +++ b/src/modules/skins/settings.py @@ -0,0 +1,54 @@ +from meta.errors import UserInputError +from settings.data import ModelData +from settings.setting_types import StringSetting +from settings.groups import SettingGroup + +from wards import sys_admin_iward +from core.data import CoreData +from gui.base import AppSkin +from babel.translator import ctx_translator + +from . import babel + +_p = babel._p + + +class GlobalSkinSettings(SettingGroup): + class DefaultSkin(ModelData, StringSetting): + setting_id = 'default_app_skin' + _write_ward = sys_admin_iward + + _display_name = _p( + 'botset:default_app_skin', "default_skin" + ) + _desc = _p( + 'botset:default_app_skin|desc', + "The skin name of the app skin to use as the global default." + ) + _long_desc = _p( + 'botset:default_app_skin|long_desc', + "The skin name, as given in the `skins.json` file," + " of the client default interface skin." + " Guilds and users will be able to apply this skin" + "regardless of whether it is set as visible in the skin configuration." + ) + _accepts = _p( + 'botset:default_app_skin|accepts', + "A valid skin name as given in skins.json" + ) + + _model = CoreData.BotConfig + _column = CoreData.BotConfig.default_skin.name + + @classmethod + async def _parse_string(cls, parent_id, string, **kwargs): + t = ctx_translator.get().t + if string and not AppSkin.get_skin_path(string): + raise UserInputError( + t(_p( + 'botset:default_app_skin|parse|error:invalid', + "Provided `{string}` is not a valid skin id!" + )).format(string=string) + ) + return string or None + diff --git a/src/modules/skins/settingui.py b/src/modules/skins/settingui.py new file mode 100644 index 00000000..705db102 --- /dev/null +++ b/src/modules/skins/settingui.py @@ -0,0 +1,100 @@ +import asyncio + +import discord +from discord.ui.select import select, Select + +from utils.ui import ConfigUI +from utils.lib import MessageArgs +from meta import LionBot + +from . import babel, logger +from .settings import GlobalSkinSettings as Settings +from .skinlib import appskin_as_option + +_p = babel._p + + +class GlobalSkinSettingUI(ConfigUI): + setting_classes = ( + Settings.DefaultSkin, + ) + + def __init__(self, bot: LionBot, appname: str, channelid: int, **kwargs): + self.cog = bot.get_cog('CustomSkinCog') + super().__init__(bot, appname, channelid, **kwargs) + + # ----- UI Components ----- + @select( + cls=Select, + placeholder="DEFAULT_APP_MENU_PLACEHOLDER", + min_values=0, max_values=1 + ) + async def default_app_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=False) + setting = self.instances[0] + + if selected.values: + setting.data = selected.values[0] + await setting.write() + else: + setting.data = None + await setting.write() + + async def default_app_menu_refresh(self): + menu = self.default_app_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:appskins|menu:default_app|placeholder', + "Select Default Skin" + )) + options = [] + for skinid in self.cog.appskin_names: + appskin = self.cog.get_base(skinid) + option = appskin_as_option(appskin) + option.default = ( + self.instances[0].value == appskin.skin_id + ) + options.append(option) + if options: + menu.options = options + else: + menu.disabled = True + menu.options = [ + discord.SelectOption(label='DUMMY') + ] + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + title = t(_p( + 'ui:appskins|embed|title', + "Leo Global Skin Settings" + )) + embed = discord.Embed( + title=title, + colour=discord.Colour.orange() + ) + for setting in self.instances: + embed.add_field(**setting.embed_field, inline=False) + + return MessageArgs(embed=embed) + + async def refresh_components(self): + to_refresh = ( + self.edit_button_refresh(), + self.close_button_refresh(), + self.reset_button_refresh(), + self.default_app_menu_refresh(), + ) + await asyncio.gather(*to_refresh) + + self.set_layout( + (self.edit_button, self.reset_button, self.close_button,), + (self.default_app_menu,), + ) + + async def reload(self): + self.instances = [ + await setting.get(self.bot.appname) + for setting in self.setting_classes + ] diff --git a/src/modules/skins/skinlib.py b/src/modules/skins/skinlib.py new file mode 100644 index 00000000..f3f0cb63 --- /dev/null +++ b/src/modules/skins/skinlib.py @@ -0,0 +1,175 @@ +from collections import defaultdict +from typing import Optional + +from frozendict import frozendict +import discord +from discord.components import SelectOption +from discord.app_commands import Choice + +from gui.base import AppSkin +from meta import LionBot +from meta.logger import log_wrap + +from .data import CustomSkinData + + +def appskin_as_option(skin: AppSkin) -> SelectOption: + """ + Create a SelectOption from the given localised AppSkin + """ + return SelectOption( + label=skin.display_name, + description=skin.description, + value=skin.skin_id, + ) + + +def appskin_as_choice(skin: AppSkin) -> Choice[str]: + """ + Create an appcmds.Choice from the given localised AppSkin + """ + return Choice( + name=skin.display_name, + value=skin.skin_id, + ) + + +class FrozenCustomSkin: + __slots__ = ('base_skin_name', 'properties') + + def __init__(self, base_skin_name: Optional[str], properties: dict[str, dict[str, str]]): + self.base_skin_name = base_skin_name + self.properties = frozendict((card, frozendict(props)) for card, props in properties.items()) + + def args_for(self, card_id: str): + args = {} + if self.base_skin_name is not None: + args["base_skin_id"] = self.base_skin_name + if card_id in self.properties: + args.update(self.properties[card_id]) + return args + + +class CustomSkin: + def __init__(self, + bot: LionBot, + base_skin_name: Optional[str]=None, + properties: dict[str, dict[str, str]] = {}, + data: Optional[CustomSkinData.CustomisedSkin]=None, + ): + self.bot = bot + self.data = data + + self.base_skin_name = base_skin_name + self.properties = properties + + @property + def cog(self): + return self.bot.get_cog('CustomSkinCog') + + @property + def skinid(self) -> Optional[int]: + return self.data.custom_skin_id if self.data else None + + @property + def base_skin_id(self) -> Optional[int]: + if self.base_skin_name is not None: + return self.cog.appskin_names.inverse[self.base_skin_name] + + @classmethod + async def fetch(cls, bot: LionBot, skinid: int) -> Optional['CustomSkin']: + """ + Fetch the specified skin from data. + """ + cog = bot.get_cog('CustomSkinCog') + row = await cog.data.CustomisedSkin.fetch(skinid) + if row is not None: + records = await cog.data.custom_skin_info.select_where( + custom_skin_id=skinid + ) + properties = defaultdict(dict) + for record in records: + card_id = record['card_id'] + prop_name = record['property_name'] + prop_value = record['property_value'] + properties[card_id][prop_name] = prop_value + if row.base_skin_id is not None: + base_skin_name = cog.appskin_names[row.base_skin_id] + else: + base_skin_name = None + self = cls(bot, base_skin_name, properties, data=row) + return self + + @log_wrap(action='Save Skin') + async def save(self): + if self.data is None: + raise ValueError("Cannot save a dataless CustomSkin") + + async with self.bot.db.connection() as conn: + self.bot.db.conn = conn + async with conn.transaction(): + skinid = self.skinid + await self.data.update(base_skin_id=self.base_skin_id) + await self.cog.data.skin_properties.delete_where(skinid=skinid) + + props = { + (card, name): value + for card, card_props in self.properties.items() + for name, value in card_props.items() + if value is not None + } + # Ensure the properties exist in cache + await self.cog.fetch_property_ids(*props.keys()) + + # Now bulk insert + await self.cog.data.skin_properties.insert_many( + ('custom_skin_id', 'property_id', 'value'), + *( + (skinid, self.cog.skin_properties[propkey], value) + for propkey, value in props.items() + ) + ) + + def resolve_propid(self, propid: int) -> tuple[str, str]: + return self.cog.skin_properties[propid] + + def __getitem__(self, propid: int) -> Optional[str]: + card, name = self.resolve_propid(propid) + return self.properties.get(card, {}).get(name, None) + + def __setitem__(self, propid: int, value: Optional[str]): + card, name = self.resolve_propid(propid) + cardprops = self.properties.get(card, None) + if value is None: + if cardprops is not None: + cardprops.pop(name, None) + else: + if cardprops is None: + cardprops = self.properties[card] = {} + cardprops[name] = value + + def __delitem__(self, propid: int): + card, name = self.resolve_propid(propid) + self.properties.get(card, {}).pop(name, None) + + def freeze(self) -> FrozenCustomSkin: + """ + Freeze the custom skin data into a memory efficient FrozenCustomSkin. + """ + return FrozenCustomSkin(self.base_skin_name, self.properties) + + def load_frozen(self, frozen: FrozenCustomSkin): + """ + Update state from the given frozen state. + """ + self.base_skin_name = frozen.base_skin_name + self.properties = dict((card, dict(props)) for card, props in frozen.properties) + return self + + def args_for(self, card_id: str): + args = {} + if self.base_skin_name is not None: + args["base_skin_id"] = self.base_skin_name + if card_id in self.properties: + args.update(self.properties[card_id]) + return args diff --git a/src/modules/skins/userskinui.py b/src/modules/skins/userskinui.py new file mode 100644 index 00000000..e69de29b From 9341afc368d590a38a1085ec478c6aac670e491a Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 27 Oct 2023 15:43:02 +0300 Subject: [PATCH 18/22] feat(skins): Apply skins to graphics. --- src/modules/pomodoro/graphics.py | 4 ++++ src/modules/statistics/graphics/goals.py | 7 ++++++- src/modules/statistics/graphics/leaderboard.py | 6 +++++- src/modules/statistics/graphics/monthly.py | 5 ++++- src/modules/statistics/graphics/profile.py | 7 ++++++- src/modules/statistics/graphics/stats.py | 6 +++++- src/modules/statistics/graphics/weekly.py | 6 +++++- 7 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/modules/pomodoro/graphics.py b/src/modules/pomodoro/graphics.py index 1d8904e2..7fc9de8b 100644 --- a/src/modules/pomodoro/graphics.py +++ b/src/modules/pomodoro/graphics.py @@ -46,6 +46,10 @@ async def get_timer_card(bot: LionBot, timer: 'Timer', stage: 'Stage'): else: card_cls = BreakTimerCard + skin = await bot.get_cog('CustomSkinCog').get_skinargs_for( + timer.data.guildid, None, card_cls.card_id + ) + return card_cls( name, remaining, diff --git a/src/modules/statistics/graphics/goals.py b/src/modules/statistics/graphics/goals.py index 86181bed..6dfe94cd 100644 --- a/src/modules/statistics/graphics/goals.py +++ b/src/modules/statistics/graphics/goals.py @@ -122,6 +122,11 @@ async def get_goals_card( badges = await data.ProfileTag.fetch_tags(guildid, userid) card_cls = WeeklyGoalCard if weekly else MonthlyGoalCard + + skin = await bot.get_cog('CustomSkinCog').get_skinargs_for( + guildid, userid, card_cls.card_id + ) + card = card_cls( name=username[0], discrim=username[1], @@ -134,6 +139,6 @@ async def get_goals_card( attendance=attendance, goals=tasks, date=today, - skin={'mode': mode} + skin=skin | {'mode': mode} ) return card diff --git a/src/modules/statistics/graphics/leaderboard.py b/src/modules/statistics/graphics/leaderboard.py index 36632634..310cfc93 100644 --- a/src/modules/statistics/graphics/leaderboard.py +++ b/src/modules/statistics/graphics/leaderboard.py @@ -60,8 +60,12 @@ async def get_leaderboard_card( highlight = position # Request Card + + skin = await bot.get_cog('CustomSkinCog').get_skinargs_for( + guildid, None, LeaderboardCard.card_id + ) card = LeaderboardCard( - skin={'mode': mode}, + skin=skin | {'mode': mode}, server_name=guild.name, entries=entries, highlight=highlight diff --git a/src/modules/statistics/graphics/monthly.py b/src/modules/statistics/graphics/monthly.py index c6855a2a..fb47e97e 100644 --- a/src/modules/statistics/graphics/monthly.py +++ b/src/modules/statistics/graphics/monthly.py @@ -121,6 +121,9 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int, username = (lion.data.display_name, '#????') # Request card + skin = await bot.get_cog('CustomSkinCog').get_skinargs_for( + guildid, userid, MonthlyStatsCard.card_id + ) card = MonthlyStatsCard( user=username, timezone=str(lion.timezone), @@ -129,6 +132,6 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int, monthly=monthly, current_streak=current_streak, longest_streak=longest_streak, - skin={'mode': mode} + skin=skin | {'mode': mode} ) return card diff --git a/src/modules/statistics/graphics/profile.py b/src/modules/statistics/graphics/profile.py index 38fac587..fc70a68c 100644 --- a/src/modules/statistics/graphics/profile.py +++ b/src/modules/statistics/graphics/profile.py @@ -80,6 +80,10 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int): achievements = await get_achievements_for(bot, guildid, userid) achieved = tuple(ach.emoji_index for ach in achievements if ach.achieved) + skin = await bot.get_cog('CustomSkinCog').get_skinargs_for( + guildid, userid, ProfileCard.card_id + ) + card = ProfileCard( user=username, avatar=(userid, avatar), @@ -88,6 +92,7 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int): achievements=achieved, current_rank=current_rank, rank_progress=rank_progress, - next_rank=next_rank + next_rank=next_rank, + skin=skin, ) return card diff --git a/src/modules/statistics/graphics/stats.py b/src/modules/statistics/graphics/stats.py index 042151b8..aa4eacaa 100644 --- a/src/modules/statistics/graphics/stats.py +++ b/src/modules/statistics/graphics/stats.py @@ -117,12 +117,16 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode if streak_start is not None: streaks.append((streak_start, today.day)) + skin = await bot.get_cog('CustomSkinCog').get_skinargs_for( + guildid, userid, StatsCard.card_id + ) + card = StatsCard( (position, 0), period_strings, month_string, 100, streaks, - skin={'mode': mode} + skin=skin | {'mode': mode} ) return card diff --git a/src/modules/statistics/graphics/weekly.py b/src/modules/statistics/graphics/weekly.py index 74b8b103..00f43358 100644 --- a/src/modules/statistics/graphics/weekly.py +++ b/src/modules/statistics/graphics/weekly.py @@ -58,6 +58,10 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int, else: username = (lion.data.display_name, '#????') + skin = await bot.get_cog('CustomSkinCog').get_skinargs_for( + guildid, userid, WeeklyStatsCard.card_id + ) + card = WeeklyStatsCard( user=username, timezone=str(lion.timezone), @@ -68,6 +72,6 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int, (int(session['start_time'].timestamp()), int(session['start_time'].timestamp() + int(session['duration']))) for session in sessions ], - skin={'mode': mode} + skin=skin | {'mode': mode} ) return card From 2f29bf37f610cdd44d74b8f759b96ae6f298d866 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 27 Oct 2023 15:43:33 +0300 Subject: [PATCH 19/22] chore: Update requirements. Add 'bidict' and 'frozendict' dependencies. --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index 011dd9dc..af59fce9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,5 @@ topggpy psutil pillow python-dateutil +bidict +frozendict From 02068bd6b919f6710caff89f85c7cf2bfe619fa8 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 28 Oct 2023 01:01:24 +0300 Subject: [PATCH 20/22] feat(skins): Implement user skin ui. --- src/gui | 2 +- src/modules/skins/cog.py | 7 +- src/modules/skins/skinlib.py | 2 +- src/modules/skins/userskinui.py | 552 ++++++++++++++++++++++++++++++++ 4 files changed, 558 insertions(+), 5 deletions(-) diff --git a/src/gui b/src/gui index f2760218..c1bcb05c 160000 --- a/src/gui +++ b/src/gui @@ -1 +1 @@ -Subproject commit f2760218ef065f1cde53b801b184cfe02f24dff0 +Subproject commit c1bcb05c25cd2ecec7dd726d55d30606b6b5c99b diff --git a/src/modules/skins/cog.py b/src/modules/skins/cog.py index 49d3a003..95711066 100644 --- a/src/modules/skins/cog.py +++ b/src/modules/skins/cog.py @@ -21,6 +21,7 @@ from .data import CustomSkinData from .skinlib import appskin_as_choice, FrozenCustomSkin, CustomSkin from .settings import GlobalSkinSettings from .settingui import GlobalSkinSettingUI +from .userskinui import UserSkinUI _p = babel._p @@ -35,7 +36,6 @@ class CustomSkinCog(LionCog): # After initialisation, contains all the base skins available for this app self.appskin_names: bidict[int, str] = bidict() - # TODO: Move this to a bijection # Bijective cache of skin property ids <-> (card_id, property_name) tuples self.skin_properties: bidict[int, tuple[str, str]] = bidict() @@ -245,8 +245,9 @@ class CustomSkinCog(LionCog): async def cmd_my_skin(self, ctx: LionContext): if not ctx.interaction: return - # TODO - ... + ui = UserSkinUI(self.bot, ctx.author.id, ctx.author.id) + await ui.run(ctx.interaction, ephemeral=True) + await ui.wait() # ----- Adminspace commands ----- @LionCog.placeholder_group diff --git a/src/modules/skins/skinlib.py b/src/modules/skins/skinlib.py index f3f0cb63..4dc172b3 100644 --- a/src/modules/skins/skinlib.py +++ b/src/modules/skins/skinlib.py @@ -91,7 +91,7 @@ class CustomSkin: for record in records: card_id = record['card_id'] prop_name = record['property_name'] - prop_value = record['property_value'] + prop_value = record['value'] properties[card_id][prop_name] = prop_value if row.base_skin_id is not None: base_skin_name = cog.appskin_names[row.base_skin_id] diff --git a/src/modules/skins/userskinui.py b/src/modules/skins/userskinui.py index e69de29b..210f67f2 100644 --- a/src/modules/skins/userskinui.py +++ b/src/modules/skins/userskinui.py @@ -0,0 +1,552 @@ +from typing import Optional +import asyncio +import datetime as dt + +import discord +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.select import select, Select, SelectOption +from gui.base.AppSkin import AppSkin +from gui.base.Card import Card + +from meta import LionBot, conf +from meta.errors import ResponseTimedOut, UserInputError +from meta.logger import log_wrap +from modules.premium.data import GemTransactionType +from modules.premium.errors import BalanceTooLow +from modules.skins.skinlib import CustomSkin, appskin_as_option +from utils.ui import MessageUI, input, Confirm +from utils.lib import MessageArgs, utc_now +from gui import cards + +from . import babel, logger +from .data import CustomSkinData as Data + +_p = babel._p + + +class UserSkinUI(MessageUI): + card_classes = [ + cards.ProfileCard, + cards.StatsCard, + cards.WeeklyGoalCard, + cards.WeeklyStatsCard, + cards.MonthlyGoalCard, + cards.MonthlyStatsCard, + ] + + def __init__(self, bot: LionBot, userid: int, callerid: int, **kwargs): + super().__init__(callerid=callerid, **kwargs) + + self.bot = bot + self.cog = bot.get_cog('CustomSkinCog') + self.gems = bot.get_cog('PremiumCog') + + self.userid = userid + + # UI State + # Map of app_skin_id -> itemid + self.inventory: dict[str, int] = {} + + # Active app skin, if any + self.active: Optional[str] = None + + # Skins available for purchase + self.available = self._get_available() + + # Index of card currently showing + self._card: int = 0 + + # Name of skin currently displayed, 'default' for default + self._skin: Optional[str] = None + + self.balance: int = 0 + + @property + def current_card(self) -> Card: + return self.card_classes[self._card] + + @property + def current_skin(self) -> AppSkin: + if self._skin is None: + raise ValueError("Cannot get current skin before load.") + return self.available[self._skin] + + @property + def is_default(self) -> bool: + return (self._skin == 'default') + + @property + def is_owned(self) -> bool: + return self.is_default or (self._skin in self.inventory) + + @property + def is_equipped(self) -> bool: + return (self.active == self._skin) or (self.is_default and not self.active) + + def _get_available(self) -> dict[str, AppSkin]: + skins = { + skin.skin_id: skin for skin in AppSkin.get_all() + if skin.public or ( + skin.user_whitelist is not None and + self.userid in skin.user_whitelist + ) + } + skins['default'] = self._make_default() + return skins + + def _make_default(self) -> AppSkin: + """ + Create a placeholder 'default' skin. + """ + t = self.bot.translator.t + + skin = AppSkin(None) + skin.skin_id = 'default' + skin.display_name = t(_p( + 'ui:userskins|default_skin:display_name', + "Default" + )) + skin.description = t(_p( + 'ui:userskins|default_skin:description', + "My default interface theme" + )) + skin.price = 0 + return skin + + # ----- UI API ----- + + @log_wrap(action='equip skin') + async def _equip_owned_skin(self, itemid: Optional[int]): + """ + Equip the provided item. + + if `itemid` is None, 'equips' the default skin. + """ + # Global dispatch + await self.cog.data.UserSkin.table.update_where( + userid=self.userid + ).set(active=False) + if itemid is not None: + await self.cog.data.UserSkin.table.update_where( + userid=self.userid, itemid=itemid + ).set(active=True) + + await self.bot.global_dispatch('userset_skin', self.userid) + + @log_wrap(action='purchase skin') + async def _purchase_skin(self, app_skin_name: str): + async with self.bot.db.connection() as conn: + self.bot.db.conn = conn + async with conn.transaction(): + skin = self.current_skin + skinid = self.cog.appskin_names.inverse[skin.skin_id] + + # Perform transaction + transaction = await self.gems.gem_transaction( + GemTransactionType.PURCHASE, + actorid=self.userid, + from_account=self.userid, + to_account=None, + amount=skin.price, + description=( + f"User purchased custom app skin {skin.skin_id} via UserSkinUI." + ), + note=None, + reference=f"iid: {self._original.id if self._original else 'None'}" + ) + + # Create custom skin + custom_skin = await self.cog.data.CustomisedSkin.create( + base_skin_id=skinid, + ) + + # Update inventory actives + await self.cog.data.UserSkin.table.update_where( + userid=self.userid + ).set(active=False) + + # Insert into inventory + await self.cog.data.UserSkin.create( + userid=self.userid, + custom_skin_id=custom_skin.custom_skin_id, + transactionid=transaction.transactionid, + active=True + ) + + # Global dispatch update + await self.bot.global_dispatch('userset_skin', self.userid) + + logger.info( + f" purchased skin {skin.skin_id}." + ) + + # ----- UI Components ----- + + # Gift Button + @button( + label="GIFT_BUTTON_PLACEHOLDER", + style=ButtonStyle.green, + ) + async def gift_button(self, press: discord.Interaction, pressed: Button): + # TODO: Replace with an actual gifting interface + + t = self.bot.translator.t + skin = self.current_skin + gift_hint = t(_p( + 'ui:userskins|button:gift|response', + "To gift **{skin}** to a friend," + " send them {gem}**{price}** with {gift_cmd}." + )).format( + skin=skin.display_name, + gem=self.bot.config.emojis.gem, + price=skin.price, + gift_cmd=self.bot.core.mention_cmd('gift'), + ) + await press.response.send_message(gift_hint, ephemeral=True) + + async def gift_button_refresh(self): + button = self.gift_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:userskins|button:gift|label', + "Gift to a friend" + )) + price = self.current_skin.price + button.disabled = ( + not price or (price > self.balance) + ) + + # Purchase Button + @button( + label="PURCHASE_BUTTON_PLACEHOLDER", + style=ButtonStyle.green + ) + async def purchase_button(self, press: discord.Interaction, pressed: Button): + t = self.bot.translator.t + + skin = self.current_skin + + # Verify we can purchase this skin + await self.reload() + + if self.is_owned: + raise UserInputError( + t(_p( + 'ui:userskins|button:purchase|error:already_owned', + "You already own this skin!" + )) + ) + elif skin.price > self.balance: + raise UserInputError( + t(_p( + 'ui:userskins|button:purchase|error:insufficient_gems', + "You don't have enough LionGems to purchase this skin!" + )) + ) + + # Confirm purchase + confirm_msg = t(_p( + 'ui:userskins|button:purchase|confirm|desc', + "Are you sure you want to purchase this skin?\n" + "The price of the skin is {gem}**{price}**." + )).format(price=skin.price, gem=self.bot.config.emojis.gem) + confirm = Confirm(confirm_msg, press.user.id) + + confirm.embed.set_footer( + text=t(_p( + 'ui:userskins|button:purchase|confirm|footer', + "Your current balance is {balance} LionGems" + )).format(balance=self.balance) + ) + + try: + result = await confirm.ask(press, ephemeral=True) + except ResponseTimedOut: + result = False + + if result: + try: + await self._purchase_skin(skin.skin_id) + except BalanceTooLow: + raise UserInputError( + t(_p( + 'ui:userskins|button:purchase|error:insufficient_gems_post_confirm', + "Insufficient LionGems to purchase this skin!" + )) + ) + + # Ack purchase and refresh + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'ui:userskins|button:purchase|embed:success|title', + "Skin Purchase" + )), + description=t(_p( + 'ui:userskins|button:purchase|embed:success|desc', + "You have purchased and equipped the skin **{name}**!\n" + "Thank you for your support, and enjoy your new purchase!" + )).format(name=skin.display_name) + ) + await press.followup.send(embed=embed, ephemeral=True) + await self.refresh() + + async def purchase_button_refresh(self): + button = self.purchase_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:userskins|button:purchase|label', + "Purchase Skin" + )) + button.disabled = ( + self.is_owned + or self.current_skin.price > self.balance + ) + + # Equip Button + @button( + label="EQUIP_BUTTON_PLACEHOLDER", + style=ButtonStyle.green + ) + async def equip_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + t = self.bot.translator.t + + to_equip = None if self.is_default else self.inventory[self._skin] + await self._equip_owned_skin(to_equip) + + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'ui:userskins|button:equip|embed:success|title', + "Skin Equipped" + )), + description=t(_p( + 'ui:userskins|button:equip|embed:success|desc', + "You have equpped your **{name}** skin!" + )).format(name=self.current_skin.display_name) + ) + await press.edit_original_response(embed=embed) + await self.refresh() + + async def equip_button_refresh(self): + button = self.equip_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:userskins|button:equip|label', + "Equip Skin" + )) + button.disabled = ( + self.is_equipped or not self.is_owned + ) + + # Price button + @button( + label="PRICE_BUTTON_PLACEHOLDER", + style=ButtonStyle.green, + emoji=conf.emojis.gem, + ) + async def price_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=False) + + async def price_button_refresh(self): + button = self.price_button + t = self.bot.translator.t + + price = self.current_skin.price + button.label = t(_p( + 'ui:userskins|button:price|label', + "{price} Gems" + )).format(price=price) + if price < self.balance: + button.style = ButtonStyle.green + else: + button.style = ButtonStyle.danger + + # Card Menu + @select( + cls=Select, + placeholder="CARD_MENU_PLACEHOLDER", + min_values=1, max_values=1 + ) + async def card_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=True, ephemeral=True) + self._card = int(selected.values[0]) + await self.refresh(thinking=selection) + + async def card_menu_refresh(self): + menu = self.card_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:userskins|menu:card|placeholder', + "Select a card to preview" + )) + options = [] + for i, card in enumerate(self.card_classes): + option = SelectOption( + label=t(card.display_name), + value=str(i), + default=(i == self._card) + ) + options.append(option) + menu.options = options + + @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) + async def quit_button(self, press: discord.Interaction, pressed: Button): + """ + Quit the UI. + """ + await press.response.defer() + await self.quit() + + # Skin Menu + @select( + cls=Select, + placeholder="SKIN_MENU_PLACEHOLDER", + min_values=1, max_values=1 + ) + async def skin_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=True, ephemeral=True) + self._skin = selected.values[0] + await self.refresh(thinking=selection) + + async def skin_menu_refresh(self): + menu = self.skin_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:userskins|menu:skin|placeholder', + "Select a skin." + )) + options = [] + for skin in self.available.values(): + option = appskin_as_option(skin) + if skin.skin_id == self._skin: + option.default = True + options.append(option) + menu.options = options + + # ----- UI Flow ----- + async def _render_card(self) -> discord.File: + if not self._skin: + raise ValueError("Rendering UserSkinUI before load.") + + use_skin = None + if self._skin == 'default': + use_skin = await self.cog.get_default_skin() + else: + use_skin = self._skin + skin = {'base_skin_id': use_skin} if use_skin else {} + + return await self.current_card.generate_sample(skin=skin) + + async def make_message(self) -> MessageArgs: + if not self._skin: + raise ValueError("Rendering UserSkinUI before load.") + + t = self.bot.translator.t + + skin = self.current_skin + + # Compute tagline + if not self.is_owned: + if skin.price <= self.balance: + tagline = t(_p( + 'ui:userskins|tagline:purchase', + "Purchase this skin for {gem}{price}" + )) + else: + tagline = t(_p( + 'ui:userskins|tagline:insufficient', + "You don't have enough LionGems to buy this skin!" + )) + elif not self.is_equipped: + tagline = t(_p( + 'ui:userskins|tagline:equip', + "You already own this skin! Clock Equip to use it!" + )) + else: + tagline = t(_p( + 'ui:userskins|tagline:current', + "This is your current skin!" + )) + + tagline = tagline.format( + gem=self.bot.config.emojis.gem, + price=skin.price, + ) + + embed = discord.Embed( + colour=discord.Colour.orange(), + title=skin.display_name, + description=f"{skin.description}\n\n***{tagline}***" + ) + embed.set_footer( + icon_url="https://cdn.discordapp.com/attachments/925799205954543636/938703943683416074/4CF1C849-D532-4DEC-B4C9-0AB11F443BAB.png", + text=t(_p( + 'ui:userskins|footer', + "Current Balance: {balance} LionGems" + )).format(balance=self.balance) + ) + embed.set_image(url='attachment://sample.png') + + file = await self._render_card() + + return MessageArgs(embed=embed, files=[file]) + + async def refresh_layout(self): + """ + (gift_button, price_button, action_button) + (skin_menu,), + (card_menu,), + """ + to_refresh = ( + self.gift_button_refresh(), + self.price_button_refresh(), + self.purchase_button_refresh(), + self.equip_button_refresh(), + self.card_menu_refresh(), + self.skin_menu_refresh(), + ) + await asyncio.gather(*to_refresh) + + # Determine action button + skin = self.current_skin + if not self.is_owned: + if skin.price <= self.balance: + action = self.purchase_button + else: + action = self.gems.buy_gems_button() + else: + action = self.equip_button + + self.set_layout( + (self.gift_button, self.price_button, action, self.quit_button,), + (self.skin_menu,), + (self.card_menu,), + ) + + async def reload(self): + """ + Load the user's skin inventory. + """ + records = await self.cog.data.UserSkin.table.select_where( + userid=self.userid + ).join( + 'customised_skins', using=('custom_skin_id',) + ).select( + 'itemid', 'custom_skin_id', 'base_skin_id', 'active' + ).with_no_adapter() + active = None + inventory = {} + for record in records: + base_skin_name = self.cog.appskin_names[record['base_skin_id']] + inventory[base_skin_name] = record['itemid'] + if record['active']: + active = base_skin_name + + self.inventory = inventory + self.active = active + if self._skin is None: + self._skin = active or 'default' + + self.balance = await self.gems.get_gem_balance(self.userid) From d1b9a95bd2498c005a276eb23a67d24732db9a8d Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 29 Oct 2023 03:30:49 +0200 Subject: [PATCH 21/22] feat(skins): Implement CustomSkin editor. --- src/meta/LionBot.py | 6 +- src/modules/skins/cog.py | 55 +- src/modules/skins/editor/layout.py | 133 ++++ src/modules/skins/editor/pages/__init__.py | 16 + src/modules/skins/editor/pages/leaderboard.py | 294 +++++++++ src/modules/skins/editor/pages/monthly.py | 376 +++++++++++ .../skins/editor/pages/monthly_goals.py | 436 +++++++++++++ src/modules/skins/editor/pages/profile.py | 266 ++++++++ src/modules/skins/editor/pages/stats.py | 211 ++++++ src/modules/skins/editor/pages/summary.py | 89 +++ src/modules/skins/editor/pages/weekly.py | 350 ++++++++++ .../skins/editor/pages/weekly_goals.py | 438 +++++++++++++ src/modules/skins/editor/skineditor.py | 598 ++++++++++++++++++ src/modules/skins/editor/skinsetting.py | 298 +++++++++ src/modules/skins/settings.py | 1 + src/modules/skins/skinlib.py | 44 +- 16 files changed, 3585 insertions(+), 26 deletions(-) diff --git a/src/meta/LionBot.py b/src/meta/LionBot.py index 9d514c67..4b32df48 100644 --- a/src/meta/LionBot.py +++ b/src/meta/LionBot.py @@ -13,7 +13,7 @@ from aiohttp import ClientSession from data import Database from utils.lib import tabulate from gui.errors import RenderingException -from babel.translator import ctx_locale +from babel.translator import ctx_locale, LeoBabel from .config import Conf from .logger import logging_context, log_context, log_action_stack, log_wrap, set_logging_context @@ -54,9 +54,9 @@ logger = logging.getLogger(__name__) class LionBot(Bot): def __init__( - self, *args, appname: str, shardname: str, db: Database, config: Conf, + self, *args, appname: str, shardname: str, db: Database, config: Conf, translator: LeoBabel, initial_extensions: List[str], web_client: ClientSession, app_ipc, - testing_guilds: List[int] = [], translator=None, **kwargs + testing_guilds: List[int] = [], **kwargs ): kwargs.setdefault('tree_cls', LionTree) super().__init__(*args, **kwargs) diff --git a/src/modules/skins/cog.py b/src/modules/skins/cog.py index 95711066..b142def1 100644 --- a/src/modules/skins/cog.py +++ b/src/modules/skins/cog.py @@ -10,6 +10,7 @@ from frozendict import frozendict from meta import LionCog, LionBot, LionContext +from meta.errors import UserInputError from meta.logger import log_wrap from utils.lib import MISSING, utc_now from wards import sys_admin_ward, low_management_ward @@ -22,6 +23,7 @@ from .skinlib import appskin_as_choice, FrozenCustomSkin, CustomSkin from .settings import GlobalSkinSettings from .settingui import GlobalSkinSettingUI from .userskinui import UserSkinUI +from .editor.skineditor import CustomSkinEditor _p = babel._p @@ -46,6 +48,8 @@ class CustomSkinCog(LionCog): # Cache of custom skin id -> frozen custom skin self.custom_skins: LRUCache[int, FrozenCustomSkin] = LRUCache(maxsize=1000) + self.current_default: Optional[str] = None + async def cog_load(self): await self.data.init() @@ -61,6 +65,7 @@ class CustomSkinCog(LionCog): await self._reload_appskins() await self._reload_property_map() + await self.get_default_skin() async def _reload_property_map(self): """ @@ -123,6 +128,7 @@ class CustomSkinCog(LionCog): """ setting = self.bot_settings.DefaultSkin instance = await setting.get(self.bot.appname) + self.current_default = instance.value return instance.value async def fetch_property_ids(self, *card_properties: tuple[str, str]) -> list[int]: @@ -203,7 +209,7 @@ class CustomSkinCog(LionCog): skin_args = await self.args_for_skin(skinid, card_id) args.update(skin_args) - default = await self.get_default_skin() + default = self.current_default if default: args.setdefault("base_skin_id", default) @@ -224,11 +230,20 @@ class CustomSkinCog(LionCog): Update cached args for given custom skin id. """ self.custom_skins.pop(skinid, None) - custom_skin = await CustomSkin.fetch(skinid) + custom_skin = await CustomSkin.fetch(self.bot, skinid) if custom_skin is not None: skin = custom_skin.freeze() self.custom_skins[skinid] = skin + @LionCog.listener('on_botset_skin') + async def handle_botset_skin(self, appname, instance): + await self.bot.global_dispatch('global_botset_skin', appname) + + @LionCog.listener('on_global_botset_skin') + async def refresh_default_skin(self, appname): + await self.bot.core.data.BotConfig.fetch(appname, cached=False) + await self.get_default_skin() + # ----- Userspace commands ----- @LionCog.placeholder_group @cmds.hybrid_group("my", with_app_command=False) @@ -268,8 +283,40 @@ class CustomSkinCog(LionCog): return if not ctx.guild: return - # TODO - ... + t = self.bot.translator.t + + # Check guild premium status + premiumcog = self.bot.get_cog('PremiumCog') + guild_row = await premiumcog.data.PremiumGuild.fetch(ctx.guild.id, cached=False) + + if not guild_row: + raise UserInputError( + t(_p( + 'cmd:admin_brand|error:not_premium', + "Only premium servers can modify their interface theme! " + "Use the {premium} command to upgrade your server." + )).format(premium=self.bot.core.mention_cmd('premium')) + ) + + await ctx.interaction.response.defer(thinking=True, ephemeral=False) + + if guild_row.custom_skin_id is None: + # Create new custom skin + skin_data = await self.data.CustomisedSkin.create( + base_skin_id=self.appskin_names.inverse[self.current_default] if self.current_default else None + ) + await guild_row.update(custom_skin_id=skin_data.custom_skin_id) + + skinid = guild_row.custom_skin_id + custom_skin = await CustomSkin.fetch(self.bot, skinid) + if custom_skin is None: + raise ValueError("Invalid custom skin id") + + # Open the CustomSkinEditor with this skin + ui = CustomSkinEditor(custom_skin, callerid=ctx.author.id) + await ui.send(ctx.channel) + await ctx.interaction.delete_original_response() + await ui.wait() # ----- Owner commands ----- @LionCog.placeholder_group diff --git a/src/modules/skins/editor/layout.py b/src/modules/skins/editor/layout.py index e69de29b..ad0630ad 100644 --- a/src/modules/skins/editor/layout.py +++ b/src/modules/skins/editor/layout.py @@ -0,0 +1,133 @@ +from typing import Optional +from dataclasses import dataclass, field + +import uuid +import discord +from discord.components import SelectOption + +from babel.translator import LazyStr +from gui.base.Card import Card +from utils.lib import EmbedField, tabulate + +from .skinsetting import SettingInputType, Setting +from ..skinlib import CustomSkin + + +@dataclass +class SettingGroup: + """ + Data class representing a collection of settings which are naturally + grouped together at interface level. + + Typically the settings in a single SettingGroup are displayed + in the same embed field, the settings are edited with the same modal, + and the group represents a single option in the "setting group menu". + + Setting groups do not correspond to any grouping at the Card or Skin level, + and may cross multiple cards. + """ + + # The name and description strings are shown in the embed field and menu option + name: LazyStr + + # Tuple of settings that are part of this setting group + settings: tuple[Setting, ...] + + description: Optional[LazyStr] = None + + # Whether the group should be displayed in a group or not + ungrouped: bool = False + + # Whether the embed field should be inline + inline: bool = True + + # Component custom id to identify the editing component + # Also used as the value of the select option + custom_id: str = str(uuid.uuid4()) + + @property + def editable_settings(self): + return tuple(setting for setting in self.settings if setting.input_type is SettingInputType.ModalInput) + + def embed_field_for(self, skin: CustomSkin) -> EmbedField: + """ + Tabulates the contained settings and builds an embed field for the editor UI. + """ + t = skin.bot.translator.t + + rows: list[tuple[str, str]] = [] + for setting in self.settings: + name = t(setting.display_name) + value = setting.value_in(skin) or setting.default_value_in(skin) + formatted = setting.format_value_in(skin, value) + rows.append((name, formatted)) + + lines = tabulate(*rows) + table = '\n'.join(lines) + + description = f"*{t(self.description)}*" if self.description else '' + + embed_field = EmbedField( + name=t(self.name), + value=f"{description}\n{table}", + inline=self.inline, + ) + return embed_field + + def select_option_for(self, skin: CustomSkin) -> SelectOption: + """ + Makes a SelectOption referring to this setting group. + """ + t = skin.bot.translator.t + option = SelectOption( + label=t(self.name), + description=t(self.description) if self.description else None, + value=self.custom_id, + ) + return option + + +@dataclass +class Page: + """ + Represents a page of skin settings for the skin editor UI. + """ + # Various string attributes of the page + display_name: LazyStr + editing_description: Optional[LazyStr] = None + preview_description: Optional[LazyStr] = None + + visible_in_preview: bool = True + render_card: Optional[type[Card]] = None + + groups: list[SettingGroup] = field(default_factory=list) + + def make_embed_for(self, skin: CustomSkin) -> discord.Embed: + t = skin.bot.translator.t + + embed = discord.Embed( + colour=discord.Colour.orange(), + title=t(self.display_name), + ) + + description_lines: list[str] = [] + field_counter = 0 + + for group in self.groups: + field = group.embed_field_for(skin) + if group.ungrouped: + description_lines.append(field.value) + else: + embed.add_field(**field._asdict()) + if not (field_counter) % 3: + embed.add_field(name='', value='') + field_counter += 1 + field_counter += 1 + + if description_lines: + embed.description = '\n'.join(description_lines) + + if self.render_card is not None: + embed.set_image(url='attachment://sample.png') + + return embed diff --git a/src/modules/skins/editor/pages/__init__.py b/src/modules/skins/editor/pages/__init__.py index e69de29b..caa2cf21 100644 --- a/src/modules/skins/editor/pages/__init__.py +++ b/src/modules/skins/editor/pages/__init__.py @@ -0,0 +1,16 @@ +from .stats import stats_page +from .profile import profile_page +from .summary import summary_page +from .weekly import weekly_page +from .monthly import monthly_page +from .weekly_goals import weekly_goal_page +from .monthly_goals import monthly_goal_page +from .leaderboard import leaderboard_page + + +pages = [ + profile_page, stats_page, + weekly_page, monthly_page, + weekly_goal_page, monthly_goal_page, + leaderboard_page, +] diff --git a/src/modules/skins/editor/pages/leaderboard.py b/src/modules/skins/editor/pages/leaderboard.py index e69de29b..8ce00056 100644 --- a/src/modules/skins/editor/pages/leaderboard.py +++ b/src/modules/skins/editor/pages/leaderboard.py @@ -0,0 +1,294 @@ +from gui.cards import LeaderboardCard + +from ... import babel +from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting +from ..layout import Page, SettingGroup + +_p = babel._p + +""" +top_position_colour +top_name_colour +top_hours_colour + +entry_position_colour +entry_position_highlight_colour +entry_name_colour +entry_hours_colour + +header_text_colour +[subheader_name_colour, subheader_value_colour] + +entry_bg_colour +entry_bg_highlight_colour +""" + +leaderboard_page = Page( + display_name=_p('skinsettings|page:leaderboard|display_name', "Leaderboard"), + editing_description=_p( + 'skinsettings|page:leaderboard|edit_desc', + "Options for the Leaderboard pages." + ), + preview_description=None, + visible_in_preview=True, + render_card=LeaderboardCard +) + +header_text_colour = ColourSetting( + card=LeaderboardCard, + property_name='header_text_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:header_text_colour|display_name', + "Header" + ), + description=_p( + 'skinsettings|page:leaderboard|set:header_text_colour|desc', + "Text colour of the leaderboard header." + ) +) + +subheader_name_colour = ColourSetting( + card=LeaderboardCard, + property_name='subheader_name_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:subheader_name_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:leaderboard|set:subheader_name_colour|desc', + "" + ) +) + +subheader_value_colour = ColourSetting( + card=LeaderboardCard, + property_name='subheader_value_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:subheader_value_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:leaderboard|set:subheader_value_colour|desc', + "" + ) +) + +subheader_colour = ColoursSetting( + subheader_value_colour, + subheader_name_colour, + display_name=_p( + 'skinsettings|page:leaderboard|set:subheader_colour|display_name', + "Sub-Header" + ), + description=_p( + 'skinsettings|page:leaderboard|set:subheader_colour|desc', + "Text colour of the sub-header line." + ) +) + +top_position_colour = ColourSetting( + card=LeaderboardCard, + property_name='top_position_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:top_position_colour|display_name', + "Position" + ), + description=_p( + 'skinsettings|page:leaderboard|set:top_position_colour|desc', + "Top 3 position colour." + ) +) + +top_name_colour = ColourSetting( + card=LeaderboardCard, + property_name='top_name_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:top_name_colour|display_name', + "Name" + ), + description=_p( + 'skinsettings|page:leaderboard|set:top_name_colour|desc', + "Top 3 name colour." + ) +) + +top_hours_colour = ColourSetting( + card=LeaderboardCard, + property_name='top_hours_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:top_hours_colour|display_name', + "Hours" + ), + description=_p( + 'skinsettings|page:leaderboard|set:top_hours_colour|desc', + "Top 3 hours colour." + ) +) + +entry_position_colour = ColourSetting( + card=LeaderboardCard, + property_name='entry_position_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:entry_position_colour|display_name', + "Position" + ), + description=_p( + 'skinsettings|page:leaderboard|set:entry_position_colour|desc', + "Position text colour." + ) +) + +entry_position_highlight_colour = ColourSetting( + card=LeaderboardCard, + property_name='entry_position_highlight_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:entry_position_highlight_colour|display_name', + "Position (HL)" + ), + description=_p( + 'skinsettings|page:leaderboard|set:entry_position_highlight_colour|desc', + "Highlighted position colour." + ) +) + +entry_name_colour = ColourSetting( + card=LeaderboardCard, + property_name='entry_name_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:entry_name_colour|display_name', + "Name" + ), + description=_p( + 'skinsettings|page:leaderboard|set:entry_name_colour|desc', + "Entry name colour." + ) +) + +entry_hours_colour = ColourSetting( + card=LeaderboardCard, + property_name='entry_hours_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:entry_hours_colour|display_name', + "Hours" + ), + description=_p( + 'skinsettings|page:leaderboard|set:entry_hours_colour|desc', + "Entry hours colour." + ) +) + +entry_bg_colour = ColourSetting( + card=LeaderboardCard, + property_name='entry_bg_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:entry_bg_colour|display_name', + "Regular" + ), + description=_p( + 'skinsettings|page:leaderboard|set:entry_bg_colour|desc', + "Background colour of regular entries." + ) +) + +entry_bg_highlight_colour = ColourSetting( + card=LeaderboardCard, + property_name='entry_bg_highlight_colour', + display_name=_p( + 'skinsettings|page:leaderboard|set:entry_bg_highlight_colour|display_name', + "Highlighted" + ), + description=_p( + 'skinsettings|page:leaderboard|set:entry_bg_highlight_colour|desc', + "Background colour of highlighted entries." + ) +) + + +top_colour_group = SettingGroup( + _p('skinsettings|page:leaderboard|grp:top_colour', "Top 3"), + description=_p( + 'skinsettings|page:leaderboard|grp:top_colour|desc', + "Customise the text colours for the top 3 positions." + ), + custom_id='leaderboard-top', + settings=( + top_position_colour, + top_name_colour, + top_hours_colour + ) +) + +entry_text_group = SettingGroup( + _p('skinsettings|page:leaderboard|grp:entry_text', "Entry Text"), + description=_p( + 'skinsettings|page:leaderboard|grp:entry_text|desc', + "Text colours of the leaderboard entries." + ), + custom_id='leaderboard-text', + settings=( + entry_position_colour, + entry_position_highlight_colour, + entry_name_colour, + entry_hours_colour + ) +) + +entry_bg_group = SettingGroup( + _p('skinsettings|page:leaderboard|grp:entry_bg', "Entry Background"), + description=_p( + 'skinsettings|page:leaderboard|grp:entry_bg|desc', + "Background colours of the leaderboard entries." + ), + custom_id='leaderboard-bg', + settings=( + entry_bg_colour, + entry_bg_highlight_colour + ) +) + +misc_group = SettingGroup( + _p('skinsettings|page:leaderboard|grp:misc', "Miscellaneous"), + description=_p( + 'skinsettings|page:leaderboard|grp:misc|desc', + "Other miscellaneous colour settings." + ), + custom_id='leaderboard-misc', + settings=( + header_text_colour, + subheader_colour + ) +) + + +base_skin = SkinSetting( + card=LeaderboardCard, + property_name='base_skin_id', + display_name=_p( + 'skinsettings|page:leaderboard|set:base_skin|display_name', + 'Skin' + ), + description=_p( + 'skinsettings|page:leaderboard|set:base_skin|desc', + "Select a Skin Preset." + ) +) + +base_skin_group = SettingGroup( + _p('skinsettings|page:leaderboard|grp:base_skin', "Leaderboard Skin"), + description=_p( + 'skinsettings|page:leaderboard|grp:base_skin|desc', + "Asset pack and default values for the Leaderboard." + ), + custom_id='leaderboard-skin', + settings=(base_skin,), + ungrouped=True +) + +leaderboard_page.groups = [ + base_skin_group, + top_colour_group, + entry_text_group, + entry_bg_group, + misc_group +] + diff --git a/src/modules/skins/editor/pages/monthly.py b/src/modules/skins/editor/pages/monthly.py index e69de29b..20693925 100644 --- a/src/modules/skins/editor/pages/monthly.py +++ b/src/modules/skins/editor/pages/monthly.py @@ -0,0 +1,376 @@ +from gui.cards import MonthlyStatsCard + +from ... import babel +from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting +from ..layout import Page, SettingGroup + +_p = babel._p + +"""" +title_colour +[this_month_colour, last_month_colour] +[stats_key_colour, stats_value_colour] +footer_colour + +top_hours_colour +top_hours_bg_colour +top_date_colour +top_line_colour + +top_this_colour +top_this_hours_colour +top_last_colour +top_last_hours_colour + +weekday_background_colour +weekday_colour +month_background_colour +month_colour +""" + +monthly_page = Page( + display_name=_p('skinsettings|page:monthly|display_name', "Monthly Statistics"), + editing_description=_p( + 'skinsettings|page:monthly|edit_desc', + "Options for the monthly statistis card." + ), + preview_description=None, + visible_in_preview=True, + render_card=MonthlyStatsCard +) + + +title_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='title_colour', + display_name=_p( + 'skinsettings|page:monthly|set:title_colour|display_name', + 'Title' + ), + description=_p( + 'skinsettings|page:monthly|set:title_colour|desc', + "Colour of the card title." + ) +) +top_hours_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='top_hours_colour', + display_name=_p( + 'skinsettings|page:monthly|set:top_hours_colour|display_name', + 'Hours' + ), + description=_p( + 'skinsettings|page:monthly|set:top_hours_colour|desc', + "Hour axis labels." + ) +) +top_hours_bg_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='top_hours_bg_colour', + display_name=_p( + 'skinsettings|page:monthly|set:top_hours_bg_colour|display_name', + 'Hours Bg' + ), + description=_p( + 'skinsettings|page:monthly|set:top_hours_bg_colour|desc', + "Hour axis label background." + ) +) +top_line_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='top_line_colour', + display_name=_p( + 'skinsettings|page:monthly|set:top_line_colour|display_name', + 'Lines' + ), + description=_p( + 'skinsettings|page:monthly|set:top_line_colour|desc', + "Horizontal graph lines." + ) +) +top_date_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='top_date_colour', + display_name=_p( + 'skinsettings|page:monthly|set:top_date_colour|display_name', + 'Days' + ), + description=_p( + 'skinsettings|page:monthly|set:top_date_colour|desc', + "Day axis labels." + ) +) +top_this_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='top_this_colour', + display_name=_p( + 'skinsettings|page:monthly|set:top_this_colour|display_name', + 'This Month Bar' + ), + description=_p( + 'skinsettings|page:monthly|set:top_this_colour|desc', + "This month bar fill colour." + ) +) +top_last_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='top_last_colour', + display_name=_p( + 'skinsettings|page:monthly|set:top_last_colour|display_name', + 'Last Month Bar' + ), + description=_p( + 'skinsettings|page:monthly|set:top_last_colour|desc', + "Last month bar fill colour." + ) +) +top_this_hours_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='top_this_hours_colour', + display_name=_p( + 'skinsettings|page:monthly|set:top_this_hours_colour|display_name', + 'This Month Hours' + ), + description=_p( + 'skinsettings|page:monthly|set:top_this_hours_colour|desc', + "This month hour text." + ) +) +top_last_hours_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='top_last_hours_colour', + display_name=_p( + 'skinsettings|page:monthly|set:top_last_hours_colour|display_name', + 'Last Month Hours' + ), + description=_p( + 'skinsettings|page:monthly|set:top_last_hours_colour|desc', + "Last month hour text." + ) +) +this_month_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='this_month_colour', + display_name=_p( + 'skinsettings|page:monthly|set:this_month_colour|display_name', + 'This Month Legend' + ), + description=_p( + 'skinsettings|page:monthly|set:this_month_colour|desc', + "This month legend text." + ) +) +last_month_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='last_month_colour', + display_name=_p( + 'skinsettings|page:monthly|set:last_month_colour|display_name', + 'Last Month Legend' + ), + description=_p( + 'skinsettings|page:monthly|set:last_month_colour|desc', + "Last month legend text." + ) +) +legend_colour = ColoursSetting( + this_month_colour, + last_month_colour, + display_name=_p( + 'skinsettings|page:monthly|set:legend_colour|display_name', + 'Legend' + ), + description=_p( + 'skinsettings|page:monthly|set:legend_colour|desc', + "Legend text colour." + ) +) + +weekday_background_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='weekday_background_colour', + display_name=_p( + 'skinsettings|page:monthly|set:weekday_background_colour|display_name', + 'Weekday Bg' + ), + description=_p( + 'skinsettings|page:monthly|set:weekday_background_colour|desc', + "Weekday axis background colour." + ) +) +weekday_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='weekday_colour', + display_name=_p( + 'skinsettings|page:monthly|set:weekday_colour|display_name', + 'Weekdays' + ), + description=_p( + 'skinsettings|page:monthly|set:weekday_colour|desc', + "Weekday axis labels." + ) +) +month_background_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='month_background_colour', + display_name=_p( + 'skinsettings|page:monthly|set:month_background_colour|display_name', + 'Month Bg' + ), + description=_p( + 'skinsettings|page:monthly|set:month_background_colour|desc', + "Month axis label backgrounds." + ) +) +month_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='month_colour', + display_name=_p( + 'skinsettings|page:monthly|set:month_colour|display_name', + 'Months' + ), + description=_p( + 'skinsettings|page:monthly|set:month_colour|desc', + "Month axis label text." + ) +) +stats_key_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='stats_key_colour', + display_name=_p( + 'skinsettings|page:monthly|set:stats_key_colour|display_name', + 'Stat Names' + ), + description=_p( + 'skinsettings|page:monthly|set:stats_key_colour|desc', + "" + ) +) +stats_value_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='stats_value_colour', + display_name=_p( + 'skinsettings|page:monthly|set:stats_value_colour|display_name', + 'Stat Values' + ), + description=_p( + 'skinsettings|page:monthly|set:stats_value_colour|desc', + "" + ) +) +statistics_colour = ColoursSetting( + stats_key_colour, + stats_value_colour, + display_name=_p( + 'skinsettings|page:monthly|set:statistics_colour|display_name', + 'Statistics' + ), + description=_p( + 'skinsettings|page:monthly|set:statistics_colour|desc', + "Summary Statistics" + ) +) +footer_colour = ColourSetting( + card=MonthlyStatsCard, + property_name='footer_colour', + display_name=_p( + 'skinsettings|page:monthly|set:footer_colour|display_name', + 'Footer' + ), + description=_p( + 'skinsettings|page:monthly|set:footer_colour|desc', + "Footer text colour." + ) +) + +top_graph_group = SettingGroup( + _p('skinsettings|page:monthly|grp:top_graph', "Top Graph"), + description=_p( + 'skinsettings|page:monthly|grp:top_graph|desc', + "Customise the axis and style of the top graph." + ), + custom_id='monthly-top', + settings=( + top_hours_colour, + top_hours_bg_colour, + top_date_colour, + top_line_colour + ) +) + +top_hours_group = SettingGroup( + _p('skinsettings|page:monthly|grp:top_hours', "Hours"), + description=_p( + 'skinsettings|page:monthly|grp:top_hours|desc', + "Customise the colour of this week and last week." + ), + custom_id='monthly-hours', + settings=( + top_this_colour, + top_this_hours_colour, + top_last_colour, + top_last_hours_colour + ) +) + +bottom_graph_group = SettingGroup( + _p('skinsettings|page:monthly|grp:bottom_graph', "Heatmap"), + description=_p( + 'skinsettings|page:monthly|grp:bottom_graph|desc', + "Customise the axis and style of the heatmap." + ), + custom_id='monthly-heatmap', + settings=( + weekday_background_colour, + weekday_colour, + month_background_colour, + month_colour + ) +) + +misc_group = SettingGroup( + _p('skinsettings|page:monthly|grp:misc', "Miscellaneous"), + description=_p( + 'skinsettings|page:monthly|grp:misc|desc', + "Miscellaneous colour options." + ), + custom_id='monthly-misc', + settings=( + title_colour, + legend_colour, + statistics_colour, + footer_colour + ) +) + +base_skin = SkinSetting( + card=MonthlyStatsCard, + property_name='base_skin_id', + display_name=_p( + 'skinsettings|page:monthly|set:base_skin|display_name', + 'Skin' + ), + description=_p( + 'skinsettings|page:monthly|set:base_skin|desc', + "Select a Skin Preset." + ) +) + +base_skin_group = SettingGroup( + _p('skinsettings|page:monthly|grp:base_skin', "Monthly Stats Skin"), + description=_p( + 'skinsettings|page:monthly|grp:base_skin|desc', + "Asset pack and default values for the Monthly Statistics." + ), + custom_id='monthly-skin', + settings=(base_skin,), + ungrouped=True +) + +monthly_page.groups = [ + base_skin_group, + top_graph_group, + top_hours_group, + bottom_graph_group, + misc_group +] + diff --git a/src/modules/skins/editor/pages/monthly_goals.py b/src/modules/skins/editor/pages/monthly_goals.py index e69de29b..075bce72 100644 --- a/src/modules/skins/editor/pages/monthly_goals.py +++ b/src/modules/skins/editor/pages/monthly_goals.py @@ -0,0 +1,436 @@ +from gui.cards import MonthlyGoalCard + +from ... import babel +from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting +from ..layout import Page, SettingGroup + +_p = babel._p + +""" +mini_profile_name_colour +mini_profile_badge_colour +mini_profile_badge_text_colour +mini_profile_discrim_colour + +title_colour +footer_colour + +progress_bg_colour +progress_colour +[attendance_rate_colour, task_count_colour, studied_hour_colour] +[attendance_colour, task_done_colour, studied_text_colour, task_goal_colour] +task_goal_number_colour + +task_header_colour +task_done_number_colour +task_undone_number_colour +task_done_text_colour +task_undone_text_colour +""" + +monthly_goal_page = Page( + display_name=_p('skinsettings|page:monthly_goal|display_name', "Monthly Goals"), + editing_description=_p( + 'skinsettings|page:monthly_goal|edit_desc', + "Options for the monthly goal card." + ), + preview_description=None, + visible_in_preview=True, + render_card=MonthlyGoalCard +) + +title_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='title_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:title_colour|display_name', + "Title" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:title_colour|desc', + "" + ) +) + +progress_bg_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='progress_bg_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:progress_bg_colour|display_name', + "Bar Bg" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:progress_bg_colour|desc', + "" + ) +) + +progress_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='progress_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:progress_colour|display_name', + "Bar Fg" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:progress_colour|desc', + "" + ) +) + +attendance_rate_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='attendance_rate_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:attendance_rate_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:attendance_rate_colour|desc', + "" + ) +) + +attendance_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='attendance_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:attendance_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:attendance_colour|desc', + "" + ) +) + +task_count_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='task_count_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:task_count_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:task_count_colour|desc', + "" + ) +) + +task_done_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='task_done_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:task_done_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:task_done_colour|desc', + "" + ) +) + +task_goal_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='task_goal_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:task_goal_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:task_goal_colour|desc', + "" + ) +) + +task_goal_number_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='task_goal_number_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:task_goal_number_colour|display_name', + "Task Goal" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:task_goal_number_colour|desc', + "" + ) +) + +studied_text_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='studied_text_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:studied_text_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:studied_text_colour|desc', + "" + ) +) + +studied_hour_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='studied_hour_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:studied_hour_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:studied_hour_colour|desc', + "" + ) +) + +text_highlight_colour = ColoursSetting( + attendance_rate_colour, + task_count_colour, + studied_hour_colour, + display_name=_p( + 'skinsettings|page:monthly_goal|set:text_highlight_colour|display_name', + "Highlight Text" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:text_highlight_colour|desc', + "Progress text colour." + ) +) + +text_colour = ColoursSetting( + attendance_colour, + task_done_colour, + studied_text_colour, + task_goal_colour, + display_name=_p( + 'skinsettings|page:monthly_goal|set:text_colour|display_name', + "Text" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:text_colour|desc', + "Achievement description text colour." + ) +) + +task_header_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='task_header_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:task_header_colour|display_name', + "Task Header" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:task_header_colour|desc', + "" + ) +) + +task_done_number_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='task_done_number_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:task_done_number_colour|display_name', + "Checked Number" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:task_done_number_colour|desc', + "" + ) +) + +task_undone_number_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='task_undone_number_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:task_undone_number_colour|display_name', + "Unchecked Number" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:task_undone_number_colour|desc', + "" + ) +) + +task_done_text_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='task_done_text_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:task_done_text_colour|display_name', + "Checked Text" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:task_done_text_colour|desc', + "" + ) +) + +task_undone_text_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='task_undone_text_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:task_undone_text_colour|display_name', + "Unchecked Text" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:task_undone_text_colour|desc', + "" + ) +) + +footer_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='footer_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:footer_colour|display_name', + "Footer" + ), + description=_p( + 'skinsettings|page:monthly_goal|set:footer_colour|desc', + "" + ) +) + + +mini_profile_badge_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='mini_profile_badge_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:mini_profile_badge_colour|display_name', + 'Badge Background' + ), + description=_p( + 'skinsettings|page:monthly_goal|set:mini_profile_badge_colour|desc', + "Mini-profile badge background colour." + ) +) + +mini_profile_badge_text_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='mini_profile_badge_text_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:mini_profile_badge_text_colour|display_name', + 'Badge Text' + ), + description=_p( + 'skinsettings|page:monthly_goal|set:mini_profile_badge_text_colour|desc', + "" + ) +) + +mini_profile_name_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='mini_profile_name_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:mini_profile_name_colour|display_name', + 'Username' + ), + description=_p( + 'skinsettings|page:monthly_goal|set:mini_profile_name_colour|desc', + "Mini-profile username colour." + ) +) + +mini_profile_discrim_colour = ColourSetting( + card=MonthlyGoalCard, + property_name='mini_profile_discrim_colour', + display_name=_p( + 'skinsettings|page:monthly_goal|set:mini_profile_discrim_colour|display_name', + 'Discriminator' + ), + description=_p( + 'skinsettings|page:monthly_goal|set:mini_profile_discrim_colour|desc', + "Mini-profile discriminator colour." + ) +) + + +mini_profile_group = SettingGroup( + _p('skinsettings|page:monthly_goal|grp:mini_profile', "Profile"), + description=_p( + 'skinsettings|page:monthly_goal|grp:mini_profile|desc', + "Customise the mini-profile shown above the goals." + ), + custom_id='monthlygoal-profile', + settings=( + mini_profile_name_colour, + mini_profile_discrim_colour, + mini_profile_badge_colour, + mini_profile_badge_text_colour, + ) +) + +misc_group = SettingGroup( + _p('skinsettings|page:monthly_goal|grp:misc', "Miscellaneous"), + description=_p( + 'skinsettings|page:monthly_goal|grp:misc|desc', + "Other miscellaneous colours." + ), + custom_id='monthlygoal-misc', + settings=( + title_colour, + footer_colour, + ) +) + +task_colour_group = SettingGroup( + _p('skinsettings|page:monthly_goal|grp:task_colour', "Task colours"), + description=_p( + 'skinsettings|page:monthly_goal|grp:task_colour|desc', + "Text and number colours for (in)complete goals." + ), + custom_id='monthlygoal-tasks', + settings=( + task_undone_number_colour, + task_done_number_colour, + task_undone_text_colour, + task_done_text_colour, + ) +) + +progress_colour_group = SettingGroup( + _p('skinsettings|page:monthly_goal|grp:progress_colour', "Progress Colours"), + description=_p( + 'skinsettings|page:monthly_goal|grp:progress_colour|desc', + "Customise colours for the monthly achievement progress." + ), + custom_id='monthlygoal-progress', + settings=( + progress_bg_colour, + progress_colour, + text_colour, + text_highlight_colour, + task_goal_number_colour + ) +) + +base_skin = SkinSetting( + card=MonthlyGoalCard, + property_name='base_skin_id', + display_name=_p( + 'skinsettings|page:monthly_goal|set:base_skin|display_name', + 'Skin' + ), + description=_p( + 'skinsettings|page:monthly_goal|set:base_skin|desc', + "Select a Skin Preset." + ) +) + +base_skin_group = SettingGroup( + _p('skinsettings|page:monthly_goal|grp:base_skin', "Monthly Goals Skin"), + description=_p( + 'skinsettings|page:monthly_goal|grp:base_skin|desc', + "Asset pack and default values for the Monthly Goals." + ), + custom_id='monthlygoals-skin', + settings=(base_skin,), + ungrouped=True +) + +monthly_goal_page.groups = [ + base_skin_group, + mini_profile_group, + misc_group, + progress_colour_group, + task_colour_group +] + diff --git a/src/modules/skins/editor/pages/profile.py b/src/modules/skins/editor/pages/profile.py index e69de29b..d8e07bcf 100644 --- a/src/modules/skins/editor/pages/profile.py +++ b/src/modules/skins/editor/pages/profile.py @@ -0,0 +1,266 @@ +from gui.cards import ProfileCard + +from ... import babel +from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting +from ..layout import Page, SettingGroup + +_p = babel._p + + + +profile_page = Page( + display_name=_p('skinsettings|page:profile|display_name', "Member Profile"), + editing_description=_p( + 'skinsettings|page:profile|edit_desc', + "Options for the member profile card." + ), + preview_description=None, + visible_in_preview=True, + render_card=ProfileCard +) + + +header_colour_1 = ColourSetting( + card=ProfileCard, + property_name='header_colour_1', + display_name=_p( + 'skinsettings|page:profile|set:header_colour_1|display_name', + 'Username' + ), + description=_p( + 'skinsettings|page:profile|set:header_colour_1|desc', + "Text colour of the profile username." + ) +) + +header_colour_2 = ColourSetting( + card=ProfileCard, + property_name='header_colour_2', + display_name=_p( + 'skinsettings|page:profile|set:header_colour_2|display_name', + 'Discriminator' + ), + description=_p( + 'skinsettings|page:profile|set:header_colour_2|desc', + "Text colour of the profile dscriminator." + ) +) + +counter_bg_colour = ColourSetting( + card=ProfileCard, + property_name='counter_bg_colour', + display_name=_p( + 'skinsettings|page:profile|set:counter_bg_colour|display_name', + 'Background' + ), + description=_p( + 'skinsettings|page:profile|set:counter_bg_colour|desc', + "Colour of the coin/gem/gift backgrounds." + ) +) + +counter_colour = ColourSetting( + card=ProfileCard, + property_name='counter_colour', + display_name=_p( + 'skinsettings|page:profile|set:counter_colour|display_name', + 'Text' + ), + description=_p( + 'skinsettings|page:profile|set:counter_colour|desc', + "Colour of the coin/gem/gift text." + ) +) + +subheader_colour = ColourSetting( + card=ProfileCard, + property_name='subheader_colour', + display_name=_p( + 'skinsettings|page:profile|set:subheader_colour|display_name', + 'Column Header' + ), + description=_p( + 'skinsettings|page:profile|set:subheader_colour|desc', + "Colour of the Profile/Achievements header." + ) +) + +badge_text_colour = ColourSetting( + card=ProfileCard, + property_name='badge_text_colour', + display_name=_p( + 'skinsettings|page:profile|set:badge_text_colour|display_name', + 'Badge Text' + ), + description=_p( + 'skinsettings|page:profile|set:badge_text_colour|desc', + "Colour of the profile badge text." + ) +) + +badge_blob_colour = ColourSetting( + card=ProfileCard, + property_name='badge_blob_colour', + display_name=_p( + 'skinsettings|page:profile|set:badge_blob_colour|display_name', + 'Background' + ), + description=_p( + 'skinsettings|page:profile|set:badge_blob_colour|desc', + "Colour of the profile badge background." + ) +) + +rank_name_colour = ColourSetting( + card=ProfileCard, + property_name='rank_name_colour', + display_name=_p( + 'skinsettings|page:profile|set:rank_name_colour|display_name', + 'Current Rank' + ), + description=_p( + 'skinsettings|page:profile|set:rank_name_colour|desc', + "Colour of the current study rank name." + ) +) + +rank_hours_colour = ColourSetting( + card=ProfileCard, + property_name='rank_hours_colour', + display_name=_p( + 'skinsettings|page:profile|set:rank_hours_colour|display_name', + 'Required Hours' + ), + description=_p( + 'skinsettings|page:profile|set:rank_hours_colour|desc', + "Colour of the study rank hour range." + ) +) + +bar_full_colour = ColourSetting( + card=ProfileCard, + property_name='bar_full_colour', + display_name=_p( + 'skinsettings|page:profile|set:bar_full_colour|display_name', + 'Bar Full' + ), + description=_p( + 'skinsettings|page:profile|set:bar_full_colour|desc', + "Foreground progress bar colour." + ) +) + +bar_empty_colour = ColourSetting( + card=ProfileCard, + property_name='bar_empty_colour', + display_name=_p( + 'skinsettings|page:profile|set:bar_empty_colour|display_name', + 'Bar Empty' + ), + description=_p( + 'skinsettings|page:profile|set:bar_empty_colour|desc', + "Background progress bar colour." + ) +) + +next_rank_colour = ColourSetting( + card=ProfileCard, + property_name='next_rank_colour', + display_name=_p( + 'skinsettings|page:profile|set:next_rank_colour|display_name', + 'Next Rank' + ), + description=_p( + 'skinsettings|page:profile|set:next_rank_colour|desc', + "Colour of the next rank name and hours." + ) +) + +title_colour_group = SettingGroup( + _p('skinsettings|page:profile|grp:title_colour', "Title Colours"), + description=_p( + 'skinsettings|page:profile|grp:title_colour|desc', + "Header and suheader text colours." + ), + custom_id='profile-titles', + settings=( + header_colour_1, + header_colour_2, + subheader_colour + ), +) + +badge_colour_group = SettingGroup( + _p('skinsettings|page:profile|grp:badge_colour', "Profile Badge Colours"), + description=_p( + 'skinsettings|page:profile|grp:badge_colour|desc', + "Text and background for the profile badges." + ), + custom_id='profile-badges', + settings=( + badge_text_colour, + badge_blob_colour + ), +) + +counter_colour_group = SettingGroup( + _p('skinsettings|page:profile|grp:counter_colour', "Counter Colours"), + description=_p( + 'skinsettings|page:profile|grp:counter_colour|desc', + "Text and background for the coin/gem/gift counters." + ), + custom_id='profile-counters', + settings=( + counter_colour, + counter_bg_colour + ), +) + +rank_colour_group = SettingGroup( + _p('skinsettings|page:profile|grp:rank_colour', "Progress Bar"), + description=_p( + 'skinsettings|page:profile|grp:rank_colour|desc', + "Colours for the study badge/rank progress bar." + ), + custom_id='profile-progress', + settings=( + rank_name_colour, + rank_hours_colour, + next_rank_colour, + bar_full_colour, + bar_empty_colour + ), +) + +base_skin = SkinSetting( + card=ProfileCard, + property_name='base_skin_id', + display_name=_p( + 'skinsettings|page:profile|set:base_skin|display_name', + 'Skin' + ), + description=_p( + 'skinsettings|page:profile|set:base_skin|desc', + "Select a Skin Preset." + ) +) + +base_skin_group = SettingGroup( + _p('skinsettings|page:profile|grp:base_skin', "Profile Skin"), + description=_p( + 'skinsettings|page:profile|grp:base_skin|desc', + "Asset pack and default values for this card." + ), + custom_id='profile-skin', + settings=(base_skin,), + ungrouped=True +) + +profile_page.groups = [ + base_skin_group, + title_colour_group, + badge_colour_group, + rank_colour_group, + counter_colour_group, +] + diff --git a/src/modules/skins/editor/pages/stats.py b/src/modules/skins/editor/pages/stats.py index e69de29b..dd9f86c1 100644 --- a/src/modules/skins/editor/pages/stats.py +++ b/src/modules/skins/editor/pages/stats.py @@ -0,0 +1,211 @@ +from gui.cards import StatsCard + +from ... import babel +from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting +from ..layout import Page, SettingGroup + +_p = babel._p + + +stats_page = Page( + display_name=_p('skinsettings|page:stats|display_name', "Statistics"), + editing_description=_p( + 'skinsettings|page:stats|edit_desc', + "Options for the member statistics card." + ), + preview_description=None, + visible_in_preview=True, + render_card=StatsCard +) + + +header_colour = ColourSetting( + card=StatsCard, + property_name='header_colour', + display_name=_p( + 'skinsettings|page:stats|set:header_colour|display_name', + 'Titles' + ), + description=_p( + 'skinsettings|page:stats|set:header_colour|desc', + "Top header text colour." + ) +) + +stats_subheader_colour = ColourSetting( + card=StatsCard, + property_name='stats_subheader_colour', + display_name=_p( + 'skinsettings|page:stats|set:stats_subheader_colour|display_name', + 'Sections' + ), + description=_p( + 'skinsettings|page:stats|set:stats_subheader_colour|desc', + "Text colour of the Statistics section titles." + ) +) + +stats_text_colour = ColourSetting( + card=StatsCard, + property_name='stats_text_colour', + display_name=_p( + 'skinsettings|page:stats|set:stats_text_colour|display_name', + 'Statistics' + ), + description=_p( + 'skinsettings|page:stats|set:stats_text_colour|desc', + "Text colour of the Statistics section bodies." + ) +) + +col2_date_colour = ColourSetting( + card=StatsCard, + property_name='col2_date_colour', + display_name=_p( + 'skinsettings|page:stats|set:col2_date_colour|display_name', + 'Date' + ), + description=_p( + 'skinsettings|page:stats|set:col2_date_colour|desc', + "Colour of the current month and year." + ) +) + +col2_hours_colour = ColourSetting( + card=StatsCard, + property_name='col2_hours_colour', + display_name=_p( + 'skinsettings|page:stats|set:col2_hours_colour|display_name', + 'Hours' + ), + description=_p( + 'skinsettings|page:stats|set:col2_hours_colour|desc', + "Colour of the monthly hour total." + ) +) + +text_colour_group = SettingGroup( + _p('skinsettings|page:stats|grp:text_colour', "Text Colours"), + description=_p( + 'skinsettings|page:stats|grp:text_colour|desc', + "Header colours and statistics text colours." + ), + custom_id='stats-text', + settings=( + header_colour, + stats_subheader_colour, + stats_text_colour, + col2_date_colour, + col2_hours_colour + ) +) + + +cal_weekday_colour = ColourSetting( + card=StatsCard, + property_name='cal_weekday_colour', + display_name=_p( + 'skinsettings|page:stats|set:cal_weekday_colour|display_name', + 'Weekdays' + ), + description=_p( + 'skinsettings|page:stats|set:cal_weekday_colour|desc', + "Colour of the week day letters." + ), +) + +cal_number_colour = ColourSetting( + card=StatsCard, + property_name='cal_number_colour', + display_name=_p( + 'skinsettings|page:stats|set:cal_number_colour|display_name', + 'Numbers' + ), + description=_p( + 'skinsettings|page:stats|set:cal_number_colour|desc', + "General calender day colour." + ), +) + +cal_number_end_colour = ColourSetting( + card=StatsCard, + property_name='cal_number_end_colour', + display_name=_p( + 'skinsettings|page:stats|set:cal_number_end_colour|display_name', + 'Streak Ends' + ), + description=_p( + 'skinsettings|page:stats|set:cal_number_end_colour|desc', + "Day colour where streaks start or end." + ), +) + +cal_streak_middle_colour = ColourSetting( + card=StatsCard, + property_name='cal_streak_middle_colour', + display_name=_p( + 'skinsettings|page:stats|set:cal_streak_middle_colour|display_name', + 'Streak BG' + ), + description=_p( + 'skinsettings|page:stats|set:cal_streak_middle_colour|desc', + "Background colour on streak days." + ), +) + +cal_streak_end_colour = ColourSetting( + card=StatsCard, + property_name='cal_streak_end_colour', + display_name=_p( + 'skinsettings|page:stats|set:cal_streak_end_colour|display_name', + 'Streak End BG' + ), + description=_p( + 'skinsettings|page:stats|set:cal_streak_end_colour|desc', + "Background colour where streaks start/end." + ), +) + +calender_colour_group = SettingGroup( + _p('skinsettings|page:stats|grp:calender_colour', "Calender Colours"), + description=_p( + 'skinsettings|page:stats|grp:calender_colour|desc', + "Number and streak colours for the current calender." + ), + custom_id='stats-cal', + settings=( + cal_weekday_colour, + cal_number_colour, + cal_number_end_colour, + cal_streak_middle_colour, + cal_streak_end_colour + ) +) + + +base_skin = SkinSetting( + card=StatsCard, + property_name='base_skin_id', + display_name=_p( + 'skinsettings|page:stats|set:base_skin|display_name', + 'Skin' + ), + description=_p( + 'skinsettings|page:stats|set:base_skin|desc', + "Select a Skin Preset." + ) +) + +base_skin_group = SettingGroup( + _p('skinsettings|page:stats|grp:base_skin', "Statistics Skin"), + description=_p( + 'skinsettings|page:stats|grp:base_skin|desc', + "Asset pack and default values for this card." + ), + custom_id='stats-skin', + settings=(base_skin,), + ungrouped=True +) + +stats_page.groups = [base_skin_group, text_colour_group, calender_colour_group] + diff --git a/src/modules/skins/editor/pages/summary.py b/src/modules/skins/editor/pages/summary.py index e69de29b..8f31986d 100644 --- a/src/modules/skins/editor/pages/summary.py +++ b/src/modules/skins/editor/pages/summary.py @@ -0,0 +1,89 @@ +from gui.cards import ProfileCard + +from ... import babel +from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting +from ..layout import Page, SettingGroup + +from . import stats, profile + +_p = babel._p + + +summary_page = Page( + display_name=_p('skinsettings|page:summary|display_name', "Setting Summary"), + editing_description=_p( + 'skinsettings|page:summary|edit_desc', + "Simple setup for creating a unified interface theme." + ), + preview_description=_p( + 'skinsettings|page:summary|preview_desc', + "Summary of common settings across the entire interface." + ), + visible_in_preview=True, + render_card=ProfileCard +) + +name_colours = ColoursSetting( + profile.header_colour_1, + display_name=_p( + 'skinsettings|page:summary|set:name_colours|display_name', + "username colour" + ), + description=_p( + 'skinsettings|page:summary|set:name_colours|desc', + "Author username colour." + ) +) + +discrim_colours = ColoursSetting( + profile.header_colour_2, + display_name=_p( + 'skinsettings|page:summary|set:discrim_colours|display_name', + "discrim colour" + ), + description=_p( + 'skinsettings|page:summary|set:discrim_colours|desc', + "Author discriminator colour." + ) +) + +subheader_colour = ColoursSetting( + stats.header_colour, + profile.subheader_colour, + display_name=_p( + 'skinsettings|page:summary|set:subheader_colour|display_name', + "subheadings" + ), + description=_p( + 'skinsettings|page:summary|set:subheader_colour|desc', + "Colour of subheadings and column headings." + ) +) + +header_colour_group = SettingGroup( + _p('skinsettings|page:summary|grp:header_colour', "Title Colours"), + description=_p( + 'skinsettings|page:summary|grp:header_colour|desc', + "Title and header text colours." + ), + custom_id='shared-titles', + settings=( + subheader_colour, + ) +) + +profile_colour_group = SettingGroup( + _p('skinsettings|page:summary|grp:profile_colour', "Profile Colours"), + description=_p( + 'skinsettings|page:summary|grp:profile_colour|desc', + "Profile elements shared across various cards." + ), + custom_id='shared-profile', + settings=( + name_colours, + discrim_colours + ) +) + +summary_page.groups = [header_colour_group, profile_colour_group] + diff --git a/src/modules/skins/editor/pages/weekly.py b/src/modules/skins/editor/pages/weekly.py index e69de29b..e4eff376 100644 --- a/src/modules/skins/editor/pages/weekly.py +++ b/src/modules/skins/editor/pages/weekly.py @@ -0,0 +1,350 @@ +from gui.cards import WeeklyStatsCard + +from ... import babel +from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting +from ..layout import Page, SettingGroup + +_p = babel._p + + +weekly_page = Page( + display_name=_p('skinsettings|page:weekly|display_name', "Weekly Statistics"), + editing_description=_p( + 'skinsettings|page:weekly|edit_desc', + "Options for the weekly statistis card." + ), + preview_description=None, + visible_in_preview=True, + render_card=WeeklyStatsCard +) + +title_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='title_colour', + display_name=_p( + 'skinsettings|page:weekly|set:title_colour|display_name', + 'Title' + ), + description=_p( + 'skinsettings|page:weekly|set:title_colour|desc', + "Colour of the card title." + ) +) +top_hours_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='top_hours_colour', + display_name=_p( + 'skinsettings|page:weekly|set:top_hours_colour|display_name', + 'Hours' + ), + description=_p( + 'skinsettings|page:weekly|set:top_hours_colour|desc', + "Hours axis labels." + ) +) +top_hours_bg_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='top_hours_bg_colour', + display_name=_p( + 'skinsettings|page:weekly|set:top_hours_bg_colour|display_name', + 'Hour Bg' + ), + description=_p( + 'skinsettings|page:weekly|set:top_hours_bg_colour|desc', + "Hours axis label background." + ) +) +top_line_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='top_line_colour', + display_name=_p( + 'skinsettings|page:weekly|set:top_line_colour|display_name', + 'Lines' + ), + description=_p( + 'skinsettings|page:weekly|set:top_line_colour|desc', + "Horizontal graph lines." + ) +) +top_weekday_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='top_weekday_colour', + display_name=_p( + 'skinsettings|page:weekly|set:top_weekday_colour|display_name', + 'Weekdays' + ), + description=_p( + 'skinsettings|page:weekly|set:top_weekday_colour|desc', + "Weekday axis labels." + ) +) +top_date_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='top_date_colour', + display_name=_p( + 'skinsettings|page:weekly|set:top_date_colour|display_name', + 'Dates' + ), + description=_p( + 'skinsettings|page:weekly|set:top_date_colour|desc', + "Weekday date axis labels." + ) +) +top_this_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='top_this_colour', + display_name=_p( + 'skinsettings|page:weekly|set:top_this_colour|display_name', + 'This Week' + ), + description=_p( + 'skinsettings|page:weekly|set:top_this_colour|desc', + "This week bar fill colour." + ) +) +top_last_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='top_last_colour', + display_name=_p( + 'skinsettings|page:weekly|set:top_last_colour|display_name', + 'Last Week' + ), + description=_p( + 'skinsettings|page:weekly|set:top_last_colour|desc', + "Last week bar fill colour." + ) +) +btm_weekday_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='btm_weekday_colour', + display_name=_p( + 'skinsettings|page:weekly|set:btm_weekday_colour|display_name', + 'Weekdays' + ), + description=_p( + 'skinsettings|page:weekly|set:btm_weekday_colour|desc', + "Weekday axis labels." + ) +) +btm_weekly_background_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='btm_weekly_background_colour', + display_name=_p( + 'skinsettings|page:weekly|set:btm_weekly_background_colour|display_name', + 'Weekday Bg' + ), + description=_p( + 'skinsettings|page:weekly|set:btm_weekly_background_colour|desc', + "Weekday axis background." + ) +) +btm_bar_horiz_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='btm_bar_horiz_colour', + display_name=_p( + 'skinsettings|page:weekly|set:btm_bar_horiz_colour|display_name', + 'Bars (Horiz)' + ), + description=_p( + 'skinsettings|page:weekly|set:btm_bar_horiz_colour|desc', + "Horizontal graph bars." + ) +) +btm_bar_vert_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='btm_bar_vert_colour', + display_name=_p( + 'skinsettings|page:weekly|set:btm_bar_vert_colour|display_name', + 'Bars (Vertical)' + ), + description=_p( + 'skinsettings|page:weekly|set:btm_bar_vert_colour|desc', + "Vertical graph bars." + ) +) +btm_this_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='btm_this_colour', + display_name=_p( + 'skinsettings|page:weekly|set:btm_this_colour|display_name', + 'This Week' + ), + description=_p( + 'skinsettings|page:weekly|set:btm_this_colour|desc', + "This week bar fill colour." + ) +) +btm_last_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='btm_last_colour', + display_name=_p( + 'skinsettings|page:weekly|set:btm_last_colour|display_name', + 'Last Week' + ), + description=_p( + 'skinsettings|page:weekly|set:btm_last_colour|desc', + "Last week bar fill colour." + ) +) +btm_day_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='btm_day_colour', + display_name=_p( + 'skinsettings|page:weekly|set:btm_day_colour|display_name', + 'Hours' + ), + description=_p( + 'skinsettings|page:weekly|set:btm_day_colour|desc', + "Hour axis labels." + ) +) +this_week_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='this_week_colour', + display_name=_p( + 'skinsettings|page:weekly|set:this_week_colour|display_name', + 'This Week Legend' + ), + description=_p( + 'skinsettings|page:weekly|set:this_week_colour|desc', + "This week legend text." + ) +) +last_week_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='last_week_colour', + display_name=_p( + 'skinsettings|page:weekly|set:last_week_colour|display_name', + 'Last Week Legend' + ), + description=_p( + 'skinsettings|page:weekly|set:last_week_colour|desc', + "Last week legend text." + ) +) +legend_colour = ColoursSetting( + this_week_colour, + last_week_colour, + display_name=_p( + 'skinsettings|page:weekly|set:legend_colour|display_name', + 'Legend' + ), + description=_p( + 'skinsettings|page:weekly|set:legend_colour|desc', + "Legend text colour." + ) +) +footer_colour = ColourSetting( + card=WeeklyStatsCard, + property_name='footer_colour', + display_name=_p( + 'skinsettings|page:weekly|set:footer_colour|display_name', + 'Footer' + ), + description=_p( + 'skinsettings|page:weekly|set:footer_colour|desc', + "Footer text colour." + ) +) + +base_skin = SkinSetting( + card=WeeklyStatsCard, + property_name='base_skin_id', + display_name=_p( + 'skinsettings|page:weekly|set:base_skin|display_name', + 'Skin' + ), + description=_p( + 'skinsettings|page:weekly|set:base_skin|desc', + "Select a Skin Preset." + ) +) + +base_skin_group = SettingGroup( + _p('skinsettings|page:weekly|grp:base_skin', "Weekly Stats Skin"), + description=_p( + 'skinsettings|page:weekly|grp:base_skin|desc', + "Asset pack and default values for this card." + ), + custom_id='weekly-skin', + settings=(base_skin,), + ungrouped=True +) + +top_colour_group = SettingGroup( + _p('skinsettings|page:weekly|grp:top_colour', "Top Graph"), + description=_p( + 'skinsettings|page:weekly|grp:top_colour|desc', + "Customise the top graph colourscheme." + ), + custom_id='weekly-top', + settings=( + top_hours_colour, + top_weekday_colour, + top_date_colour, + top_this_colour, + top_last_colour, + ) +) + +bottom_colour_group = SettingGroup( + _p('skinsettings|page:weekly|grp:bottom_colour', "Bottom Graph"), + description=_p( + 'skinsettings|page:weekly|grp:bottom_colour|desc', + "Customise the bottom graph colourscheme." + ), + custom_id='weekly-bottom', + settings=( + btm_weekday_colour, + btm_day_colour, + btm_this_colour, + btm_last_colour, + btm_bar_horiz_colour, + ) +) + +misc_group = SettingGroup( + _p('skinsettings|page:weekly|grp:misc', "Misc Colours"), + description=_p( + 'skinsettings|page:weekly|grp:misc|desc', + "Miscellaneous card colours." + ), + custom_id='weekly-misc', + settings=( + title_colour, + legend_colour, + footer_colour, + ) +) + +base_skin = SkinSetting( + card=WeeklyStatsCard, + property_name='base_skin_id', + display_name=_p( + 'skinsettings|page:weekly|set:base_skin|display_name', + 'Skin' + ), + description=_p( + 'skinsettings|page:weekly|set:base_skin|desc', + "Select a Skin Preset." + ) +) + +base_skin_group = SettingGroup( + _p('skinsettings|page:weekly|grp:base_skin', "Weekly Stats Skin"), + description=_p( + 'skinsettings|page:weekly|grp:base_skin|desc', + "Asset pack and default values for the Weekly Statistics." + ), + custom_id='weekly-skin', + settings=(base_skin,), + ungrouped=True +) + +weekly_page.groups = [ + base_skin_group, + top_colour_group, + bottom_colour_group, + misc_group, +] + diff --git a/src/modules/skins/editor/pages/weekly_goals.py b/src/modules/skins/editor/pages/weekly_goals.py index e69de29b..f81b449d 100644 --- a/src/modules/skins/editor/pages/weekly_goals.py +++ b/src/modules/skins/editor/pages/weekly_goals.py @@ -0,0 +1,438 @@ +from gui.cards import WeeklyGoalCard + +from ... import babel +from ..skinsetting import ColourSetting, SkinSetting, ColoursSetting +from ..layout import Page, SettingGroup + +_p = babel._p + + +""" +mini_profile_name_colour +mini_profile_badge_colour +mini_profile_badge_text_colour +mini_profile_discrim_colour + +title_colour +footer_colour + +progress_bg_colour +progress_colour +[attendance_rate_colour, task_count_colour, studied_hour_colour] +[attendance_colour, task_done_colour, studied_text_colour, task_goal_colour] +task_goal_number_colour + +task_header_colour +task_done_number_colour +task_undone_number_colour +task_done_text_colour +task_undone_text_colour +""" + +weekly_goal_page = Page( + display_name=_p('skinsettings|page:weekly_goal|display_name', "Weekly Goals"), + editing_description=_p( + 'skinsettings|page:weekly_goal|edit_desc', + "Options for the weekly goal card." + ), + preview_description=None, + visible_in_preview=True, + render_card=WeeklyGoalCard +) + +title_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='title_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:title_colour|display_name', + "Title" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:title_colour|desc', + "" + ) +) + +progress_bg_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='progress_bg_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:progress_bg_colour|display_name', + "Bar Bg" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:progress_bg_colour|desc', + "" + ) +) + +progress_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='progress_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:progress_colour|display_name', + "Bar Fg" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:progress_colour|desc', + "" + ) +) + +attendance_rate_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='attendance_rate_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:attendance_rate_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:attendance_rate_colour|desc', + "" + ) +) + +attendance_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='attendance_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:attendance_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:attendance_colour|desc', + "" + ) +) + +task_count_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='task_count_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:task_count_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:task_count_colour|desc', + "" + ) +) + +task_done_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='task_done_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:task_done_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:task_done_colour|desc', + "" + ) +) + +task_goal_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='task_goal_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:task_goal_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:task_goal_colour|desc', + "" + ) +) + +task_goal_number_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='task_goal_number_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:task_goal_number_colour|display_name', + "Task Goal" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:task_goal_number_colour|desc', + "" + ) +) + +studied_text_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='studied_text_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:studied_text_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:studied_text_colour|desc', + "" + ) +) + +studied_hour_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='studied_hour_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:studied_hour_colour|display_name', + "" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:studied_hour_colour|desc', + "" + ) +) + +text_highlight_colour = ColoursSetting( + attendance_rate_colour, + task_count_colour, + studied_hour_colour, + display_name=_p( + 'skinsettings|page:weekly_goal|set:text_highlight_colour|display_name', + "Highlight Text" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:text_highlight_colour|desc', + "Progress text colour." + ) +) + +text_colour = ColoursSetting( + attendance_colour, + task_done_colour, + studied_text_colour, + task_goal_colour, + display_name=_p( + 'skinsettings|page:weekly_goal|set:text_colour|display_name', + "Text" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:text_colour|desc', + "Achievement description text colour." + ) +) + +task_header_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='task_header_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:task_header_colour|display_name', + "Task Header" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:task_header_colour|desc', + "" + ) +) + +task_done_number_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='task_done_number_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:task_done_number_colour|display_name', + "Checked Number" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:task_done_number_colour|desc', + "" + ) +) + +task_undone_number_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='task_undone_number_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:task_undone_number_colour|display_name', + "Unchecked Number" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:task_undone_number_colour|desc', + "" + ) +) + +task_done_text_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='task_done_text_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:task_done_text_colour|display_name', + "Checked Text" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:task_done_text_colour|desc', + "" + ) +) + +task_undone_text_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='task_undone_text_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:task_undone_text_colour|display_name', + "Unchecked Text" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:task_undone_text_colour|desc', + "" + ) +) + +footer_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='footer_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:footer_colour|display_name', + "Footer" + ), + description=_p( + 'skinsettings|page:weekly_goal|set:footer_colour|desc', + "" + ) +) + + +mini_profile_badge_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='mini_profile_badge_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:mini_profile_badge_colour|display_name', + 'Badge Background' + ), + description=_p( + 'skinsettings|page:weekly_goal|set:mini_profile_badge_colour|desc', + "Mini-profile badge background colour." + ) +) + +mini_profile_badge_text_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='mini_profile_badge_text_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:mini_profile_badge_text_colour|display_name', + 'Badge Text' + ), + description=_p( + 'skinsettings|page:weekly_goal|set:mini_profile_badge_text_colour|desc', + "" + ) +) + +mini_profile_name_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='mini_profile_name_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:mini_profile_name_colour|display_name', + 'Username' + ), + description=_p( + 'skinsettings|page:weekly_goal|set:mini_profile_name_colour|desc', + "Mini-profile username colour." + ) +) + +mini_profile_discrim_colour = ColourSetting( + card=WeeklyGoalCard, + property_name='mini_profile_discrim_colour', + display_name=_p( + 'skinsettings|page:weekly_goal|set:mini_profile_discrim_colour|display_name', + 'Discriminator' + ), + description=_p( + 'skinsettings|page:weekly_goal|set:mini_profile_discrim_colour|desc', + "Mini-profile discriminator colour." + ) +) + + +mini_profile_group = SettingGroup( + _p('skinsettings|page:weekly_goal|grp:mini_profile', "Profile"), + description=_p( + 'skinsettings|page:weekly_goal|grp:mini_profile|desc', + "Customise the mini-profile shown above the goals." + ), + custom_id='weeklygoal-profile', + settings=( + mini_profile_name_colour, + mini_profile_discrim_colour, + mini_profile_badge_colour, + mini_profile_badge_text_colour, + ) +) + +misc_group = SettingGroup( + _p('skinsettings|page:weekly_goal|grp:misc', "Miscellaneous"), + description=_p( + 'skinsettings|page:weekly_goal|grp:misc|desc', + "Other miscellaneous colours." + ), + custom_id='weeklygoal-misc', + settings=( + title_colour, + footer_colour, + ) +) + +task_colour_group = SettingGroup( + _p('skinsettings|page:weekly_goal|grp:task_colour', "Task colours"), + description=_p( + 'skinsettings|page:weekly_goal|grp:task_colour|desc', + "Text and number colours for (in)complete goals." + ), + custom_id='weeklygoal-tasks', + settings=( + task_undone_number_colour, + task_done_number_colour, + task_undone_text_colour, + task_done_text_colour, + ) +) + +progress_colour_group = SettingGroup( + _p('skinsettings|page:weekly_goal|grp:progress_colour', "Progress Colours"), + description=_p( + 'skinsettings|page:weekly_goal|grp:progress_colour|desc', + "Customise colours for the weekly achievement progress." + ), + custom_id='weeklygoal-progress', + settings=( + progress_bg_colour, + progress_colour, + text_colour, + text_highlight_colour, + task_goal_number_colour + ) +) + +base_skin = SkinSetting( + card=WeeklyGoalCard, + property_name='base_skin_id', + display_name=_p( + 'skinsettings|page:weekly_goal|set:base_skin|display_name', + 'Skin' + ), + description=_p( + 'skinsettings|page:weekly_goal|set:base_skin|desc', + "Select a Skin Preset." + ) +) + +base_skin_group = SettingGroup( + _p('skinsettings|page:weekly_goal|grp:base_skin', "Weekly Goals Skin"), + description=_p( + 'skinsettings|page:weekly_goal|grp:base_skin|desc', + "Asset pack and default values for the Weekly Goals." + ), + custom_id='weeklygoals-skin', + settings=(base_skin,), + ungrouped=True +) + + +weekly_goal_page.groups = [ + base_skin_group, + mini_profile_group, + misc_group, + progress_colour_group, + task_colour_group +] + diff --git a/src/modules/skins/editor/skineditor.py b/src/modules/skins/editor/skineditor.py index e69de29b..f05e66d0 100644 --- a/src/modules/skins/editor/skineditor.py +++ b/src/modules/skins/editor/skineditor.py @@ -0,0 +1,598 @@ +from io import StringIO +import json +from typing import Optional +import asyncio +import datetime as dt + +import discord +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.select import select, Select, SelectOption +from gui.base.AppSkin import AppSkin + +from meta import LionBot, conf +from meta.errors import ResponseTimedOut, UserInputError +from meta.logger import log_wrap +from utils.ui import FastModal, Confirm, MessageUI, error_handler_for, ModalRetryUI, AButton, AsComponents +from utils.lib import MessageArgs, utc_now +from constants import DATA_VERSION + +from .. import babel, logger +from ..skinlib import CustomSkin, FrozenCustomSkin, appskin_as_option +from .pages import pages +from .skinsetting import Setting, SettingInputType, SkinSetting +from .layout import SettingGroup, Page + + +_p = babel._p + + +class SettingInput(FastModal): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @error_handler_for(UserInputError) + async def rerequest(self, interaction, error): + await ModalRetryUI(self, error.msg).respond_to(interaction) + + +class CustomSkinEditor(MessageUI): + def _init_children(self): + # HACK to stop ViewWeights complaining that this UI has too many children + # Children will be correctly initialised after parent init. + return [] + + def __init__(self, skin: CustomSkin, **kwargs): + super().__init__(timeout=600, **kwargs) + self._children = super()._init_children() + + self.skin = skin + self.bot = skin.bot + self.cog = self.bot.get_cog('CustomSkinCog') + + self.global_themes = self._get_available() + + # UI State + + # Whether we are currently in customisation mode + self.customising = False + self.page_index = 0 + self.showing_skin_setting: Optional[SkinSetting] = None + + # Last item in history is current state + # Last item in future is next state + self.history = [skin.freeze()] + self.future = [] + self.dirty = False + + @property + def page(self) -> Page: + return pages[self.page_index] + + # ----- UI API ----- + def push_state(self): + """ + Push a state onto the history stack. + Run this on each change _before_ the refresh. + """ + state = self.skin.freeze() + self.history.append(state) + self.future.clear() + self.dirty = True + + def _get_available(self) -> dict[str, AppSkin]: + skins = { + skin.skin_id: skin for skin in AppSkin.get_all() + if skin.public + } + skins['default'] = self._make_default() + return skins + + def _make_default(self) -> AppSkin: + """ + Create a placeholder 'default' skin. + """ + t = self.bot.translator.t + + skin = AppSkin(None) + skin.skin_id = 'default' + skin.display_name = t(_p( + 'ui:skineditor|default_skin:display_name', + "Default" + )) + skin.description = t(_p( + 'ui:skineditor|default_skin:description', + "My default interface theme" + )) + skin.price = 0 + return skin + + # ----- UI Components ----- + + # Download button + # NOTE: property_id, card_id, property_name, value + # Metadata with version, time generated, skinid, generating user + # Special field for the global_skin_id + @button( + label="DOWNLOAD_BUTTON_PLACEHOLDER", + style=ButtonStyle.blurple + ) + async def download_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + + data = {} + data['metadata'] = { + 'requested_by': press.user.id, + 'requested_in': press.guild.id if press.guild else None, + 'skinid': self.skin.skinid, + 'created_at': utc_now().isoformat(), + 'data_version': DATA_VERSION, + } + data['custom_skin'] = { + 'skinid': self.skin.skinid, + 'base_skin': self.skin.base_skin_name, + } + properties = {} + for card, card_props in self.skin.properties.items(): + props = {} + for name, value in card_props.items(): + propid = (await self.cog.fetch_property_ids((card, name)))[0] + props[name] = { + 'property_id': propid, + 'value': value + } + properties[card] = props + data['custom_skin']['properties'] = properties + + content = json.dumps(data, indent=2) + with StringIO(content) as fp: + fp.seek(0) + file = discord.File(fp, filename=f"skin-{self.skin.skinid}.json") + await press.followup.send("Here is your custom skin data!", file=file, ephemeral=True) + + async def download_button_refresh(self): + button = self.download_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:skineditor|button:download|label', + "Download" + )) + + # Save button + @button( + label="SAVE_BUTTON_PLACEHOLDER", + style=ButtonStyle.green + ) + async def save_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + await self.skin.save() + self.history = self.history[-1:] + self.dirty = False + await self.refresh(thinking=press) + + async def save_button_refresh(self): + button = self.save_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:skineditor|button:save|label', + "Save" + )) + button.disabled = not self.dirty + + # Back button + @button( + label="BACK_BUTTON_PLACEHOLDER", + style=ButtonStyle.red + ) + async def back_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + self.customising = False + await self.refresh(thinking=press) + + async def back_button_refresh(self): + button = self.back_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:skineditor|button:back|label', + "Back" + )) + + # Customise button + @button( + label="CUSTOMISE_BUTTON_PLACEHOLDER", + style=ButtonStyle.green + ) + async def customise_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + self.customising = True + await self.refresh(thinking=press) + + async def customise_button_refresh(self): + button = self.customise_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:skineditor|button:customise|label', + "Customise" + )) + + # Reset card button + @button( + label="RESET_CARD_BUTTON_PLACEHOLDER", + style=ButtonStyle.red + ) + async def reset_card_button(self, press: discord.Interaction, pressed: Button): + # Note this actually resets the page, not the card + await press.response.defer(thinking=True, ephemeral=True) + + for group in self.page.groups: + for setting in group.settings: + setting.set_in(self.skin, None) + + self.push_state() + await self.refresh(thinking=press) + + async def reset_card_button_refresh(self): + button = self.reset_card_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:skineditor|button:reset_card|label', + "Reset Card" + )) + + # Reset all button + @button( + label="RESET_ALL_BUTTON_PLACEHOLDER", + style=ButtonStyle.red + ) + async def reset_all_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + + self.skin.properties.clear() + self.skin.base_skin_name = None + + self.push_state() + await self.refresh(thinking=press) + + async def reset_all_button_refresh(self): + button = self.reset_all_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:skineditor|button:reset_all|label', + "Reset All" + )) + + # Page selector + @select( + cls=Select, + placeholder="PAGE_MENU_PLACEHOLDER", + min_values=1, max_values=1 + ) + async def page_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=True, ephemeral=True) + self.page_index = int(selected.values[0]) + self.showing_skin_setting = None + await self.refresh(thinking=selection) + + async def page_menu_refresh(self): + menu = self.page_menu + t = self.bot.translator.t + + options = [] + if not self.customising: + menu.placeholder = t(_p( + 'ui:skineditor|menu:page|placeholder:preview', + "Select a card to preview" + )) + for i, page in enumerate(pages): + if page.visible_in_preview: + option = SelectOption( + label=t(page.display_name), + value=str(i), + description=t(page.preview_description) if page.preview_description else None + ) + option.default = (i == self.page_index) + options.append(option) + else: + menu.placeholder = t(_p( + 'ui:skineditor|menu:page|placeholder:edit', + "Select a card to customise" + )) + for i, page in enumerate(pages): + option = SelectOption( + label=t(page.display_name), + value=str(i), + description=t(page.editing_description) if page.editing_description else None + ) + option.default = (i == self.page_index) + options.append(option) + menu.options = options + + # Setting group selector + @select( + cls=Select, + placeholder="GROUP_MENU_PLACEHOLDER", + min_values=1, max_values=1 + ) + async def group_menu(self, selection: discord.Interaction, selected: Select): + groupid = selected.values[0] + group = next(group for group in self.page.groups if group.custom_id == groupid) + + if group.settings[0].input_type is SettingInputType.SkinInput: + self.showing_skin_setting = group.settings[0] + await selection.response.defer(thinking=True, ephemeral=True) + await self.refresh(thinking=selection) + else: + await self._launch_group_editor(selection, group) + + async def _launch_group_editor(self, interaction: discord.Interaction, group: SettingGroup): + t = self.bot.translator.t + + editable = group.editable_settings + items = [ + setting.make_input_field(self.skin) + for setting in editable + ] + modal = SettingInput(*items, title=t(group.name)) + + @modal.submit_callback() + async def group_modal_callback(interaction: discord.Interaction): + values = [] + for item, setting in zip(items, editable): + value = await setting.parse_input(self.skin, item.value) + values.append(value) + + await interaction.response.defer(thinking=True, ephemeral=True) + for value, setting in zip(values, editable): + setting.set_in(self.skin, value) + + self.push_state() + await self.refresh(thinking=interaction) + + await interaction.response.send_modal(modal) + + async def group_menu_refresh(self): + menu = self.group_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:skineditor|menu:group|placeholder', + "Select a group or option to customise" + )) + options = [] + for group in self.page.groups: + option = group.select_option_for(self.skin) + options.append(option) + menu.options = options + + # Base skin selector + @select( + cls=Select, + placeholder="SKIN_MENU_PLACEHOLDER", + min_values=1, max_values=1 + ) + async def skin_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=True, ephemeral=True) + + skin_id = selected.values[0] + if skin_id == 'default': + skin_id = None + + if self.customising: + if self.showing_skin_setting: + # Update the current page card with this skin id. + self.showing_skin_setting.set_in(self.skin, skin_id) + else: + # Far more brutal + # Update the global base skin id, and wipe the base skin id for each card + self.skin.base_skin_name = skin_id + for card_id in self.skin.properties: + self.skin.set_prop(card_id, 'base_skin_id', None) + + self.push_state() + await self.refresh(thinking=selection) + + async def skin_menu_refresh(self): + menu = self.skin_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:skineditor|menu:skin|placeholder', + "Select a theme" + )) + options = [] + for skin in self.global_themes.values(): + option = appskin_as_option(skin) + options.append(option) + menu.options = options + + # Quit button, with confirmation + @button(style=ButtonStyle.grey, emoji=conf.emojis.cancel) + async def quit_button(self, press: discord.Interaction, pressed: Button): + # Confirm quit if there are unsaved changes + if self.dirty: + t = self.bot.translator.t + confirm_msg = t(_p( + 'ui:skineditor|button:quit|confirm', + "You have unsaved changes! Are you sure you want to quit?" + )) + confirm = Confirm(confirm_msg, self._callerid) + confirm.confirm_button.label = t(_p( + 'ui:skineditor|button:quit|confirm|button:yes', + "Yes, Quit Now" + )) + confirm.confirm_button.style = ButtonStyle.red + confirm.cancel_button.style = ButtonStyle.green + confirm.cancel_button.label = t(_p( + 'ui:skineditor|button:quit|confirm|button:no', + "No, Go Back" + )) + try: + result = await confirm.ask(press, ephemeral=True) + except ResponseTimedOut: + result = False + + if result: + await self.quit() + else: + await self.quit() + + @button(label="UNDO_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def undo_button(self, press: discord.Interaction, pressed: Button): + """ + Pop the history stack. + """ + if len(self.history) > 1: + state = self.history.pop() + self.future.append(state) + + current = self.history[-1] + self.skin.load_frozen(current) + + await press.response.defer(thinking=True, ephemeral=True) + await self.refresh(thinking=press) + + async def undo_button_refresh(self): + t = self.bot.translator.t + button = self.undo_button + button.label = t(_p( + 'ui:skineditor|button:undo|label', + "Undo" + )) + button.disabled = (len(self.history) <= 1) + + @button(label="REDO_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def redo_button(self, press: discord.Interaction, pressed: Button): + """ + Pop the future stack. + """ + if len(self.future) > 0: + state = self.future.pop() + self.history.append(state) + + current = self.history[-1] + self.skin.load_frozen(current) + + await press.response.defer(thinking=True, ephemeral=True) + await self.refresh(thinking=press) + + async def redo_button_refresh(self): + t = self.bot.translator.t + button = self.redo_button + button.label = t(_p( + 'ui:skineditor|button:redo|label', + "Redo" + )) + button.disabled = (len(self.future) == 0) + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + page = self.page + + embed = page.make_embed_for(self.skin) + if page.render_card is not None: + args = self.skin.args_for(page.render_card.card_id) + args.setdefault('base_skin_id', self.cog.current_default) + file = await page.render_card.generate_sample(skin=args) + files = [file] + else: + files = [] + + return MessageArgs(embed=embed, files=files) + + async def refresh_layout(self): + """ + Customising mode: + (card_menu) + (skin_menu?) + (download, save, undo, redo, back,) + Other: + (card_menu) + (theme_menu) + (customise, save, reset_card, reset_all, X) + """ + to_refresh = ( + self.page_menu_refresh(), + self.skin_menu_refresh(), + self.undo_button_refresh(), + self.redo_button_refresh(), + self.reset_card_button_refresh(), + self.reset_all_button_refresh(), + self.customise_button_refresh(), + self.back_button_refresh(), + self.save_button_refresh(), + self.group_menu_refresh(), + self.download_button_refresh(), + ) + await asyncio.gather(*to_refresh) + + if self.customising: + self.set_layout( + (self.page_menu,), + (self.group_menu,), + (self.skin_menu,) if self.showing_skin_setting else (), + ( + self.save_button, + self.undo_button, + self.redo_button, + self.download_button, + self.back_button, + ), + ) + else: + self.set_layout( + (self.page_menu,), + (self.skin_menu,), + ( + self.customise_button, + self.save_button, + self.reset_card_button, + self.reset_all_button, + self.quit_button, + ), + ) + + async def reload(self): + ... + + async def pre_timeout(self): + # Timeout confirmation + if self.dirty: + t = self.bot.translator.t + grace_period = 60 + grace_time = utc_now() + dt.timedelta(seconds=grace_period) + embed = discord.Embed( + title=t(_p( + 'ui:skineditor|timeout_warning|title', + "Warning!" + )), + description=t(_p( + 'ui:skineditor|timeout_warning|desc', + "This interface will time out {timestamp}. Press 'Continue' below to keep editing." + )).format( + timestamp=discord.utils.format_dt(grace_time, style='R') + ), + ) + + components = None + stopped = False + + @AButton(label=t(_p('ui:skineditor|timeout_warning|continue', "Continue")), style=ButtonStyle.green) + async def cont_button(interaction: discord.Interaction, pressed): + await interaction.response.defer() + if interaction.message: + await interaction.message.delete() + nonlocal stopped + stopped = True + # TODO: Clean up this mess. It works, but needs to be refactored to a timeout confirmation mixin. + # TODO: Consider moving the message to the interaction response + self._refresh_timeout() + components.stop() + + components = AsComponents(cont_button, timeout=grace_period) + channel = self._original.channel if self._original else self._message.channel + + message = await channel.send(content=f"<@{self._callerid}>", embed=embed, view=components) + await components.wait() + + if not stopped: + try: + await message.delete() + except discord.HTTPException: + pass diff --git a/src/modules/skins/editor/skinsetting.py b/src/modules/skins/editor/skinsetting.py index e69de29b..1ddbdfc6 100644 --- a/src/modules/skins/editor/skinsetting.py +++ b/src/modules/skins/editor/skinsetting.py @@ -0,0 +1,298 @@ +from typing import Literal, Optional +from enum import Enum + +from discord.ui import TextInput + +from meta import LionBot +from meta.errors import UserInputError +from babel.translator import LazyStr +from gui.base import Card, FieldDesc, AppSkin + +from .. import babel +from ..skinlib import CustomSkin + +_p = babel._p + + +class SettingInputType(Enum): + SkinInput = -1 + ModalInput = 0 + MenuInput = 1 + ButtonInput = 2 + + +class Setting: + """ + An abstract base interface for a custom skin 'setting'. + + A skin setting is considered to be some readable and usually writeable + information extractable from a `CustomSkin`. + This will usually consist of the value of one or more properties, + which are themselves associated to fields of GUI Cards. + + The methods in this ABC describe the interface for such a setting. + Each method accepts a `CustomSkin`, + and an implementation should describe how to + get, set, parse, format, or display the setting + for that given skin. + + This is very similar to how Settings are implemented in the bot, + except here all settings have a shared external source of state, the CustomSkin. + Thus, each setting is simply an instance of an appropriate setting class, + rather than a class itself. + """ + + # What type of input method this setting requires for input + input_type: SettingInputType = SettingInputType.ModalInput + + def __init__(self, *args, display_name, description, **kwargs): + self.display_name: LazyStr = display_name + self.description: LazyStr = description + + def default_value_in(self, skin: CustomSkin) -> Optional[str]: + """ + The default value of this setting in this skin. + + This takes into account base skin data and localisation. + May be `None` if the setting does not have a default value. + """ + raise NotImplementedError + + def value_in(self, skin: CustomSkin) -> Optional[str]: + """ + The current value of this setting from this skin. + + May be None if the setting is not set or does not have a value. + Usually should not take into account defaults. + """ + raise NotImplementedError + + def set_in(self, skin: CustomSkin, value: Optional[str]): + """ + Set this setting to the given value in this skin. + """ + raise NotImplementedError + + def format_value_in(self, skin: CustomSkin, value: Optional[str]) -> str: + """ + Format the given setting value for display (typically in a setting table). + """ + raise NotImplementedError + + async def parse_input(self, skin: CustomSkin, userstr: str) -> Optional[str]: + """ + Parse a user provided string into a value for this setting. + + Will raise 'UserInputError' with a readable message if parsing fails. + """ + raise NotImplementedError + + def make_input_field(self, skin: CustomSkin) -> TextInput: + """ + Create a TextInput field for this setting, using the current value. + """ + raise NotImplementedError + + +class PropertySetting(Setting): + """ + A skin setting corresponding to a single property of a single card. + + Note that this is still abstract, + as it does not implement any formatting or parsing methods. + + This will usually (but may not always) correspond to a single Field of the card skin. + """ + def __init__(self, card: type[Card], property_name: str, **kwargs): + super().__init__(**kwargs) + self.card = card + self.property_name = property_name + + @property + def card_id(self): + """ + The `card_id` of the Card class this setting belongs to. + """ + return self.card.card_id + + @property + def field(self) -> Optional[FieldDesc]: + """ + The CardSkin field overwrriten by this setting, if it exists. + """ + return self.card.skin._fields.get(self.property_name, None) + + def default_value_in(self, skin: CustomSkin) -> Optional[str]: + """ + For a PropertySetting, the default value is determined as follows: + base skin value from: + - card base skin + - custom base skin + - global app base skin + fallback (field) value from the CardSkin + """ + base_skin = skin.get_prop(self.card_id, 'base_skin_id') + base_skin = base_skin or skin.base_skin_name + base_skin = base_skin or skin.cog.current_default + + app_skin_args = AppSkin.get(base_skin).for_card(self.card_id) + + if self.property_name in app_skin_args: + return app_skin_args[self.property_name] + elif self.field: + return self.field.default + else: + return None + + def value_in(self, skin: CustomSkin) -> Optional[str]: + return skin.get_prop(self.card_id, self.property_name) + + def set_in(self, skin: CustomSkin, value: Optional[str]): + skin.set_prop(self.card_id, self.property_name, value) + + +class _ColourInterface(Setting): + """ + Skin setting mixin for parsing and formatting colour typed settings. + """ + + def format_value_in(self, skin: CustomSkin, value: Optional[str]) -> str: + if value: + formatted = f"`{value}`" + else: + formatted = skin.bot.translator.t(_p( + 'skinsettings|colours|format:not_set', + "Not Set" + )) + return formatted + + async def parse_input(self, skin: CustomSkin, userstr: str) -> Optional[str]: + stripped = userstr.strip('# ').upper() + if not stripped: + value = None + elif len(stripped) not in (6, 8) or any(c not in '0123456789ABCDEF' for c in stripped): + raise UserInputError( + skin.bot.translator.t(_p( + 'skinsettings|colours|parse|error:invalid', + "Could not parse `{given}` as a colour!" + " Please use RGB/RGBA format (e.g. `#ABABABF0`)." + )).format(given=userstr) + ) + else: + value = f"#{stripped}" + return value + + def make_input_field(self, skin: CustomSkin) -> TextInput: + t = skin.bot.translator.t + + value = self.value_in(skin) + default_value = self.default_value_in(skin) + + label = t(self.display_name) + default = value + if default_value: + placeholder = f"{default_value} ({t(self.description)})" + else: + placeholder = t(self.description) + + return TextInput( + label=label, + placeholder=placeholder, + default=default, + min_length=0, + max_length=9, + required=False, + ) + + +class ColourSetting(_ColourInterface, PropertySetting): + """ + A Property skin setting representing a single colour field. + """ + pass + + +class SkinSetting(PropertySetting): + """ + A Property setting representing the base skin of a card. + """ + input_type = SettingInputType.SkinInput + + def format_value_in(self, skin: CustomSkin, value: Optional[str]) -> str: + if value: + app_skin = AppSkin.get(value) + formatted = f"`{app_skin.display_name}`" + else: + formatted = skin.bot.translator.t(_p( + 'skinsettings|base_skin|format:not_set', + "Default" + )) + return formatted + + def default_value_in(self, skin: CustomSkin) -> Optional[str]: + return skin.base_skin_name + + +class CompoundSetting(Setting): + """ + A Setting combining several PropertySettings across (potentially) multiple cards. + """ + NOTSHARED = '' + + def __init__(self, *settings: PropertySetting, **kwargs): + super().__init__(**kwargs) + self.settings = settings + + def default_value_in(self, skin: CustomSkin) -> Optional[str]: + """ + The default value of a CompoundSetting is the shared default of the component settings. + + If the components do not share a default value, returns None. + """ + value = None + for setting in self.settings: + setting_value = setting.default_value_in(skin) + if setting_value is None: + value = None + break + if value is None: + value = setting_value + elif value != setting_value: + value = None + break + return value + + def value_in(self, skin: CustomSkin) -> Optional[str]: + """ + The value of a compound setting is the shared value of the components. + """ + value = self.NOTSHARED + for setting in self.settings: + setting_value = setting.value_in(skin) or setting.default_value_in(skin) + + if value is self.NOTSHARED: + value = setting_value + elif value != setting_value: + value = self.NOTSHARED + break + return value + + def set_in(self, skin: CustomSkin, value: Optional[str]): + """ + Set all of the components individually. + """ + for setting in self.settings: + setting.set_in(skin, value) + + +class ColoursSetting(_ColourInterface, CompoundSetting): + """ + Compound setting representing multiple colours. + """ + def format_value_in(self, skin: CustomSkin, value: Optional[str]) -> str: + if value is self.NOTSHARED: + return "Mixed" + elif value is None: + return "Not Set" + else: + return f"`{value}`" diff --git a/src/modules/skins/settings.py b/src/modules/skins/settings.py index 2c248942..a700076c 100644 --- a/src/modules/skins/settings.py +++ b/src/modules/skins/settings.py @@ -16,6 +16,7 @@ _p = babel._p class GlobalSkinSettings(SettingGroup): class DefaultSkin(ModelData, StringSetting): setting_id = 'default_app_skin' + _event = 'botset_skin' _write_ward = sys_admin_iward _display_name = _p( diff --git a/src/modules/skins/skinlib.py b/src/modules/skins/skinlib.py index 4dc172b3..2ce57141 100644 --- a/src/modules/skins/skinlib.py +++ b/src/modules/skins/skinlib.py @@ -110,7 +110,7 @@ class CustomSkin: async with conn.transaction(): skinid = self.skinid await self.data.update(base_skin_id=self.base_skin_id) - await self.cog.data.skin_properties.delete_where(skinid=skinid) + await self.cog.data.skin_properties.delete_where(custom_skin_id=skinid) props = { (card, name): value @@ -122,31 +122,37 @@ class CustomSkin: await self.cog.fetch_property_ids(*props.keys()) # Now bulk insert - await self.cog.data.skin_properties.insert_many( - ('custom_skin_id', 'property_id', 'value'), - *( - (skinid, self.cog.skin_properties[propkey], value) - for propkey, value in props.items() + if props: + await self.cog.data.skin_properties.insert_many( + ('custom_skin_id', 'property_id', 'value'), + *( + (skinid, self.cog.skin_properties.inverse[propkey], value) + for propkey, value in props.items() + ) ) - ) + await self.bot.global_dispatch('skin_updated', skinid) + + def get_prop(self, card_id: str, prop_name: str) -> Optional[str]: + return self.properties.get(card_id, {}).get(prop_name, None) + + def set_prop(self, card_id: str, prop_name: str, value: Optional[str]): + cardprops = self.properties.get(card_id, None) + if value is None: + if cardprops is not None: + cardprops.pop(prop_name, None) + else: + if cardprops is None: + cardprops = self.properties[card_id] = {} + cardprops[prop_name] = value def resolve_propid(self, propid: int) -> tuple[str, str]: return self.cog.skin_properties[propid] def __getitem__(self, propid: int) -> Optional[str]: - card, name = self.resolve_propid(propid) - return self.properties.get(card, {}).get(name, None) + return self.get_prop(*self.resolve_propid(propid)) def __setitem__(self, propid: int, value: Optional[str]): - card, name = self.resolve_propid(propid) - cardprops = self.properties.get(card, None) - if value is None: - if cardprops is not None: - cardprops.pop(name, None) - else: - if cardprops is None: - cardprops = self.properties[card] = {} - cardprops[name] = value + return self.set_prop(*self.resolve_propid(propid), value) def __delitem__(self, propid: int): card, name = self.resolve_propid(propid) @@ -163,7 +169,7 @@ class CustomSkin: Update state from the given frozen state. """ self.base_skin_name = frozen.base_skin_name - self.properties = dict((card, dict(props)) for card, props in frozen.properties) + self.properties = dict((card, dict(props)) for card, props in frozen.properties.items()) return self def args_for(self, card_id: str): From ec0463f0e77104e2b2ff2909e3fc8dbf14b84ea4 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 29 Oct 2023 03:31:36 +0200 Subject: [PATCH 22/22] fix(ranks): Fix notification logic. --- src/modules/ranks/cog.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/modules/ranks/cog.py b/src/modules/ranks/cog.py index 8f138dc3..a204d0a4 100644 --- a/src/modules/ranks/cog.py +++ b/src/modules/ranks/cog.py @@ -592,20 +592,25 @@ class RankCog(LionCog): # Calculate destination to_dm = lguild.config.get('dm_ranks').value rank_channel = lguild.config.get('rank_channel').value + sent = False - if to_dm or not rank_channel: + if to_dm: destination = member embed.set_author( name=guild.name, icon_url=guild.icon.url if guild.icon else None ) - text = None - else: + try: + await destination.send(embed=embed) + sent = True + except discord.HTTPException: + if not rank_channel: + raise + + if not sent and rank_channel: destination = rank_channel text = member.mention - - # Post! - await destination.send(embed=embed, content=text) + await destination.send(content=text, embed=embed) def get_message_map(self, rank_type: RankType,