From 36f92add4ebcaba6bc4fb5602e7795f85a379d01 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 28 Dec 2021 15:15:47 +0200 Subject: [PATCH 01/15] (todo): Small refactor. Refactor `Tasklist` to allow for alternative (plugin) implementation. --- bot/modules/todo/Tasklist.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/bot/modules/todo/Tasklist.py b/bot/modules/todo/Tasklist.py index e16ff866..dd6b6880 100644 --- a/bot/modules/todo/Tasklist.py +++ b/bot/modules/todo/Tasklist.py @@ -135,7 +135,7 @@ class Tasklist: ) self._refreshed_at = datetime.datetime.utcnow() - def _format_tasklist(self): + async def _format_tasklist(self): """ Generates a sequence of pages from the tasklist """ @@ -176,7 +176,7 @@ class Tasklist: hint = "Type `add ` to start adding tasks! E.g. `add Revise Maths Paper 1`." task_blocks = [""] # Empty page so we can post - # Create formtted page embeds, adding help if required + # Create formatted page embeds, adding help if required pages = [] for i, block in enumerate(task_blocks): embed = discord.Embed( @@ -233,6 +233,12 @@ class Tasklist: self.message = message self.messages[message.id] = self + async def _update(self): + """ + Update the current message with the current page. + """ + await self.message.edit(embed=self.pages[self.current_page]) + async def update(self, repost=None): """ Update the displayed tasklist. @@ -243,7 +249,7 @@ class Tasklist: # Update data and make page list self._refresh() - self._format_tasklist() + await self._format_tasklist() self._adjust_current_page() if self.message and not repost: @@ -266,7 +272,8 @@ class Tasklist: if not repost: try: - await self.message.edit(embed=self.pages[self.current_page]) + # TODO: Refactor into update method + await self._update() # Add or remove paging reactions as required should_have_paging = len(self.pages) > 1 @@ -572,21 +579,21 @@ class Tasklist: self.current_page %= len(self.pages) if self.show_help: self.show_help = False - self._format_tasklist() - await self.message.edit(embed=self.pages[self.current_page]) + await self._format_tasklist() + await self._update() elif str_emoji == self.prev_emoji and user.id == self.member.id: self.current_page -= 1 self.current_page %= len(self.pages) if self.show_help: self.show_help = False - self._format_tasklist() - await self.message.edit(embed=self.pages[self.current_page]) + await self._format_tasklist() + await self._update() elif str_emoji == self.cancel_emoji and user.id == self.member.id: await self.deactivate(delete=True) elif str_emoji == self.question_emoji and user.id == self.member.id: self.show_help = not self.show_help - self._format_tasklist() - await self.message.edit(embed=self.pages[self.current_page]) + await self._format_tasklist() + await self._update() elif str_emoji == self.refresh_emoji and user.id == self.member.id: await self.update() From e2c096f350e4f75470a836fa31e53e55ea1e47b8 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 08:27:53 +0200 Subject: [PATCH 02/15] (todo): Add extra metrics for stats. v6 -> v7 data migration. Use soft deletion for tasks. Remove task expiry. Migrate `complete` field to `completed_at`. --- bot/modules/todo/Tasklist.py | 61 +++++++++++++++--------------- bot/modules/todo/data.py | 14 +------ data/migration/v6-v7/migration.sql | 18 +++++++++ data/schema.sql | 7 ++-- 4 files changed, 54 insertions(+), 46 deletions(-) create mode 100644 data/migration/v6-v7/migration.sql diff --git a/bot/modules/todo/Tasklist.py b/bot/modules/todo/Tasklist.py index dd6b6880..f1b2ebac 100644 --- a/bot/modules/todo/Tasklist.py +++ b/bot/modules/todo/Tasklist.py @@ -6,8 +6,9 @@ import asyncio from cmdClient.lib import SafeCancellation from meta import client from core import Lion +from data import NULL, NOTNULL from settings import GuildSettings -from utils.lib import parse_ranges +from utils.lib import parse_ranges, utc_now from . import data # from .module import module @@ -130,8 +131,8 @@ class Tasklist: """ self.tasklist = data.tasklist.fetch_rows_where( userid=self.member.id, - _extra=("AND last_updated_at > timezone('utc', NOW()) - INTERVAL '24h' " - "ORDER BY created_at ASC, taskid ASC") + deleted_at=NULL, + _extra="ORDER BY created_at ASC, taskid ASC" ) self._refreshed_at = datetime.datetime.utcnow() @@ -144,7 +145,7 @@ class Tasklist: "{num:>{numlen}}. [{mark}] {content}".format( num=i, numlen=((self.block_size * (i // self.block_size + 1) - 1) // 10) + 1, - mark=self.checkmark if task.complete else ' ', + mark=self.checkmark if task.completed_at else ' ', content=task.content ) for i, task in enumerate(self.tasklist) @@ -159,7 +160,7 @@ class Tasklist: # Formatting strings and data page_count = len(task_blocks) or 1 task_count = len(task_strings) - complete_count = len([task for task in self.tasklist if task.complete]) + complete_count = len([task for task in self.tasklist if task.completed_at]) if task_count > 0: title = "TODO list ({}/{} complete)".format( @@ -205,7 +206,7 @@ class Tasklist: # Calculate or adjust the current page number if self.current_page is None: # First page with incomplete task, or the first page - first_incomplete = next((i for i, task in enumerate(self.tasklist) if not task.complete), 0) + first_incomplete = next((i for i, task in enumerate(self.tasklist) if not task.completed_at), 0) self.current_page = first_incomplete // self.block_size elif self.current_page >= len(self.pages): self.current_page = len(self.pages) - 1 @@ -394,8 +395,14 @@ class Tasklist: Delete tasks from the task list """ taskids = [self.tasklist[i].taskid for i in indexes] - return data.tasklist.delete_where( - taskid=taskids + + now = utc_now() + return data.tasklist.update_where( + { + 'deleted_at': now, + 'last_updated_at': now + }, + taskid=taskids, ) def _edit_task(self, index, new_content): @@ -403,10 +410,12 @@ class Tasklist: Update the provided task with the new content """ taskid = self.tasklist[index].taskid + + now = utc_now() return data.tasklist.update_where( { 'content': new_content, - 'last_updated_at': datetime.datetime.utcnow() + 'last_updated_at': now }, taskid=taskid, ) @@ -416,13 +425,15 @@ class Tasklist: Mark provided tasks as complete """ taskids = [self.tasklist[i].taskid for i in indexes] + + now = utc_now() return data.tasklist.update_where( { - 'complete': True, - 'last_updated_at': datetime.datetime.utcnow() + 'completed_at': now, + 'last_updated_at': now }, taskid=taskids, - complete=False, + completed_at=NULL, ) def _uncheck_tasks(self, *indexes): @@ -430,13 +441,15 @@ class Tasklist: Mark provided tasks as incomplete """ taskids = [self.tasklist[i].taskid for i in indexes] + + now = utc_now() return data.tasklist.update_where( { - 'complete': False, - 'last_updated_at': datetime.datetime.utcnow() + 'completed_at': None, + 'last_updated_at': now }, taskid=taskids, - complete=True, + completed_at=NOTNULL, ) def _index_range_parser(self, userstr): @@ -466,7 +479,7 @@ class Tasklist: count = data.tasklist.select_one_where( select_columns=("COUNT(*)",), userid=self.member.id, - _extra="AND last_updated_at > timezone('utc', NOW()) - INTERVAL '24h'" + deleted_at=NOTNULL )[0] # Fetch maximum allowed count @@ -503,8 +516,8 @@ class Tasklist: # Parse provided ranges indexes = self._index_range_parser(userstr) - to_check = [index for index in indexes if not self.tasklist[index].complete] - to_uncheck = [index for index in indexes if self.tasklist[index].complete] + to_check = [index for index in indexes if not self.tasklist[index].completed_at] + to_uncheck = [index for index in indexes if self.tasklist[index].completed_at] if to_uncheck: self._uncheck_tasks(*to_uncheck) @@ -694,15 +707,3 @@ async def tasklist_message_handler(client, message): async def tasklist_reaction_add_handler(client, reaction, user): if user != client.user and reaction.message.id in Tasklist.messages: await Tasklist.messages[reaction.message.id].handle_reaction(reaction, user, True) - - -# @module.launch_task -# Commented because we don't actually need to expire these -async def tasklist_expiry_watchdog(client): - removed = data.tasklist.queries.expire_old_tasks() - if removed: - client.log( - "Remove {} stale todo tasks.".format(len(removed)), - context="TASKLIST_EXPIRY", - post=True - ) diff --git a/bot/modules/todo/data.py b/bot/modules/todo/data.py index f340197a..be2d8c65 100644 --- a/bot/modules/todo/data.py +++ b/bot/modules/todo/data.py @@ -2,23 +2,11 @@ from data import RowTable, Table tasklist = RowTable( 'tasklist', - ('taskid', 'userid', 'content', 'complete', 'rewarded', 'created_at', 'last_updated_at'), + ('taskid', 'userid', 'content', 'rewarded', 'created_at', 'completed_at', 'deleted_at', 'last_updated_at'), 'taskid' ) -@tasklist.save_query -def expire_old_tasks(): - with tasklist.conn: - with tasklist.conn.cursor() as curs: - curs.execute( - "DELETE FROM tasklist WHERE " - "last_updated_at < timezone('utc', NOW()) - INTERVAL '7d' " - "RETURNING *" - ) - return curs.fetchall() - - tasklist_channels = Table('tasklist_channels') tasklist_rewards = Table('tasklist_reward_history') diff --git a/data/migration/v6-v7/migration.sql b/data/migration/v6-v7/migration.sql new file mode 100644 index 00000000..04cc39da --- /dev/null +++ b/data/migration/v6-v7/migration.sql @@ -0,0 +1,18 @@ +-- Add deletion column to tasklist entries +-- Add completed_at column to the tasklist entries, replacing complete + + +ALTER TABLE tasklist + ADD COLUMN completed_at TIMESTAMPTZ, + ADD COLUMN deleted_at TIMESTAMPTZ, + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN last_updated_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; + +UPDATE tasklist SET deleted_at = NOW() WHERE last_updated_at < NOW() - INTERVAL '24h'; +UPDATE tasklist SET completed_at = last_updated_at WHERE complete; + +ALTER TABLE tasklist + DROP COLUMN complete; + + +-- Mark all tasklist entries older than a day as deleted diff --git a/data/schema.sql b/data/schema.sql index a7a4af31..cf960112 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -135,10 +135,11 @@ CREATE TABLE tasklist( taskid SERIAL PRIMARY KEY, userid BIGINT NOT NULL, content TEXT NOT NULL, - complete BOOL DEFAULT FALSE, rewarded BOOL DEFAULT FALSE, - created_at TIMESTAMP DEFAULT (now() at time zone 'utc'), - last_updated_at TIMESTAMP DEFAULT (now() at time zone 'utc') + deleted_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ, + last_updated_at TIMESTAMPTZ ); CREATE INDEX tasklist_users ON tasklist (userid); From 6aaa2377a4e2e2ff8642253e80a149ea63d90bd4 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 20:13:24 +0200 Subject: [PATCH 03/15] refactor: Split `stats` module from `study`. --- bot/modules/__init__.py | 1 + bot/modules/stats/__init__.py | 6 ++++++ bot/modules/stats/data.py | 13 +++++++++++++ bot/modules/stats/module.py | 4 ++++ .../{study/stats_cmd.py => stats/profile.py} | 2 +- bot/modules/{study => stats}/top_cmd.py | 0 bot/modules/study/__init__.py | 3 --- bot/modules/study/module.py | 2 +- 8 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 bot/modules/stats/__init__.py create mode 100644 bot/modules/stats/data.py create mode 100644 bot/modules/stats/module.py rename bot/modules/{study/stats_cmd.py => stats/profile.py} (99%) rename bot/modules/{study => stats}/top_cmd.py (100%) diff --git a/bot/modules/__init__.py b/bot/modules/__init__.py index 9a6a51ae..b1763cae 100644 --- a/bot/modules/__init__.py +++ b/bot/modules/__init__.py @@ -3,6 +3,7 @@ from .guild_admin import * from .meta import * from .economy import * from .study import * +from .stats import * from .user_config import * from .workout import * from .todo import * diff --git a/bot/modules/stats/__init__.py b/bot/modules/stats/__init__.py new file mode 100644 index 00000000..4478e63d --- /dev/null +++ b/bot/modules/stats/__init__.py @@ -0,0 +1,6 @@ +from .module import module + +from . import data +from . import profile +from . import setprofile +from . import top_cmd diff --git a/bot/modules/stats/data.py b/bot/modules/stats/data.py new file mode 100644 index 00000000..79bcb7a9 --- /dev/null +++ b/bot/modules/stats/data.py @@ -0,0 +1,13 @@ +from data import Table + + +profile_tags = Table('member_profile_tags', attach_as='profile_tags') + + +@profile_tags.save_query +def get_tags_for(guildid, userid): + rows = profile_tags.select_where( + guildid=guildid, userid=userid, + _extra="ORDER BY tagid ASC" + ) + return [row['tag'] for row in rows] diff --git a/bot/modules/stats/module.py b/bot/modules/stats/module.py new file mode 100644 index 00000000..d820c4de --- /dev/null +++ b/bot/modules/stats/module.py @@ -0,0 +1,4 @@ +from LionModule import LionModule + + +module = LionModule("Statistics") diff --git a/bot/modules/study/stats_cmd.py b/bot/modules/stats/profile.py similarity index 99% rename from bot/modules/study/stats_cmd.py rename to bot/modules/stats/profile.py index 88bc8be5..09239ad8 100644 --- a/bot/modules/study/stats_cmd.py +++ b/bot/modules/stats/profile.py @@ -7,7 +7,7 @@ from data import tables from data.conditions import LEQ from core import Lion -from .tracking.data import session_history +from modules.study.tracking.data import session_history from .module import module diff --git a/bot/modules/study/top_cmd.py b/bot/modules/stats/top_cmd.py similarity index 100% rename from bot/modules/study/top_cmd.py rename to bot/modules/stats/top_cmd.py diff --git a/bot/modules/study/__init__.py b/bot/modules/study/__init__.py index 30f59149..8e7830b0 100644 --- a/bot/modules/study/__init__.py +++ b/bot/modules/study/__init__.py @@ -3,6 +3,3 @@ from .module import module from . import badges from . import timers from . import tracking - -from . import top_cmd -from . import stats_cmd diff --git a/bot/modules/study/module.py b/bot/modules/study/module.py index ae88f7dd..38f5340a 100644 --- a/bot/modules/study/module.py +++ b/bot/modules/study/module.py @@ -1,4 +1,4 @@ from LionModule import LionModule -module = LionModule("Study_Stats") +module = LionModule("Study_Tracking") From 46bf03ae25b54f9acea10dbb2892f2864dd39154 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 20:20:17 +0200 Subject: [PATCH 04/15] feature (setprofile): Profile tag editor. --- bot/modules/stats/setprofile.py | 216 +++++++++++++++++++++++++++++ data/migration/v6-v7/migration.sql | 18 ++- data/schema.sql | 17 ++- 3 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 bot/modules/stats/setprofile.py diff --git a/bot/modules/stats/setprofile.py b/bot/modules/stats/setprofile.py new file mode 100644 index 00000000..766eb5ea --- /dev/null +++ b/bot/modules/stats/setprofile.py @@ -0,0 +1,216 @@ +""" +Provides a command to update a member's profile badges. +""" +import string +import discord + +from cmdClient.lib import SafeCancellation +from cmdClient.checks import in_guild +from wards import guild_moderator + +from .data import profile_tags +from .module import module + + +MAX_TAGS = 5 +MAX_LENGTH = 30 + + +@module.cmd( + "setprofile", + group="Personal Settings", + desc="Set or update your study profile tags.", + aliases=('editprofile', 'mytags'), + flags=('clear', 'for') +) +@in_guild() +async def cmd_setprofile(ctx, flags): + """ + Usage``: + {prefix}setprofile , , , ... + {prefix}setprofile + {prefix}setprofile --clear [--for @user] + Description: + Set or update the tags appearing in your study server profile. + + You can have at most `5` tags at once. + + Moderators can clear a user's tags with `--clear --for @user`. + Examples``: + {prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe + {prefix}setprofile 2 Biology + {prefix}setprofile --clear + """ + if flags['clear']: + if flags['for']: + # Moderator-clearing a user's tags + # First check moderator permissions + if not await guild_moderator.run(ctx): + return await ctx.error_reply( + "You need to be a server moderator to use this!" + ) + + # Check input and extract users to clear for + if not (users := ctx.msg.mentions): + # Show moderator usage + return await ctx.error_reply( + f"**Usage:** `{ctx.best_prefix}setprofile --clear --for @user`\n" + f"**Example:** {ctx.best_prefix}setprofile --clear --for {ctx.author.mention}" + ) + + # Clear the tags + profile_tags.delete_where( + guildid=ctx.guild.id, + userid=[user.id for user in users] + ) + + # Ack the moderator + await ctx.embed_reply( + "Profile tags cleared!" + ) + else: + # The author wants to clear their own tags + + # First delete the tags, save the rows for reporting + rows = profile_tags.delete_where( + guildid=ctx.guild.id, + userid=ctx.author.id + ) + + # Ack the user + if not rows: + await ctx.embed_reply( + "You don't have any profile tags to clear!" + ) + else: + embed = discord.Embed( + colour=discord.Colour.green(), + description="Successfully cleared your profile!" + ) + embed.add_field( + name="Removed tags", + value='\n'.join(row['tag'].upper() for row in rows) + ) + await ctx.reply(embed=embed) + elif ctx.args: + if len(splits := ctx.args.split(maxsplit=1)) > 1 and splits[0].isdigit(): + # Assume we are editing the provided id + tagid = int(splits[0]) + if tagid > MAX_TAGS: + return await ctx.error_reply( + f"Sorry, you can have a maximum of `{MAX_TAGS}` tags!" + ) + + # Retrieve the user's current taglist + rows = profile_tags.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + _extra="ORDER BY tagid ASC" + ) + + # Parse and validate provided new content + content = splits[1].strip().upper() + validate_tag(content) + + if tagid > len(rows): + # Trying to edit a tag that doesn't exist yet + # Just create it instead + profile_tags.insert( + guildid=ctx.guild.id, + userid=ctx.author.id, + tag=content + ) + + # Ack user + await ctx.reply( + embed=discord.Embed(title="Tag created!", colour=discord.Colour.green()) + ) + else: + # Get the row id to update + to_edit = rows[tagid - 1]['tagid'] + + # Update the tag + profile_tags.update_where( + {'tag': content}, + tagid=to_edit + ) + + # Ack user + embed = discord.Embed( + colour=discord.Colour.green(), + title="Tag updated!" + ) + await ctx.reply(embed=embed) + else: + # Assume the arguments are a comma separated list of badges + # Parse and validate + to_add = [split.strip().upper() for split in ctx.args.split(',')] + validate_tag(*to_add) + + # Remove the existing badges + deleted_rows = profile_tags.delete_where( + guildid=ctx.guild.id, + userid=ctx.author.id + ) + + # Insert the new tags + profile_tags.insert_many( + *((ctx.guild.id, ctx.author.id, tag) for tag in to_add), + insert_keys=('guildid', 'userid', 'tag') + ) + + # Ack with user + embed = discord.Embed( + colour=discord.Colour.green(), + description="Profile tags updated!" + ) + embed.add_field( + name="New tags", + value='\n'.join(to_add) + ) + if deleted_rows: + embed.add_field( + name="Previous tags", + value='\n'.join(row['tag'].upper() for row in deleted_rows), + inline=False + ) + await ctx.reply(embed=embed) + else: + # No input was provided + # Show usage and exit + embed = discord.Embed( + colour=discord.Colour.red(), + description=( + "Use this command to edit your study profile " + "tags so other people can see what you do!" + ) + ) + embed.add_field( + name="Usage", + value=( + f"`{ctx.best_prefix}setprofile , , , ...`\n" + f"`{ctx.best_prefix}setprofile `" + ) + ) + embed.add_field( + name="Examples", + value=( + f"`{ctx.best_prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe`\n" + f"`{ctx.best_prefix}setprofile 2 Biology`" + ), + inline=False + ) + await ctx.reply(embed=embed) + + +def validate_tag(*content): + for content in content: + if not set(content).issubset(string.printable): + raise SafeCancellation( + f"Invalid tag `{content}`!\n" + "Tags may only contain alphanumeric and punctuation characters." + ) + if len(content) > MAX_LENGTH: + raise SafeCancellation( + f"Provided tag is too long! Please keep your tags shorter than {MAX_LENGTH} characters." + ) diff --git a/data/migration/v6-v7/migration.sql b/data/migration/v6-v7/migration.sql index 04cc39da..3d4db031 100644 --- a/data/migration/v6-v7/migration.sql +++ b/data/migration/v6-v7/migration.sql @@ -1,7 +1,4 @@ --- Add deletion column to tasklist entries --- Add completed_at column to the tasklist entries, replacing complete - - +-- Improved tasklist statistics ALTER TABLE tasklist ADD COLUMN completed_at TIMESTAMPTZ, ADD COLUMN deleted_at TIMESTAMPTZ, @@ -15,4 +12,15 @@ ALTER TABLE tasklist DROP COLUMN complete; --- Mark all tasklist entries older than a day as deleted +-- New member profile tags +CREATE TABLE member_profile_tags( + tagid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + tag TEXT NOT NULL, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) +); + + +INSERT INTO VersionHistory (version, author) VALUES (7, 'v6-v7 migration'); diff --git a/data/schema.sql b/data/schema.sql index cf960112..5edb3add 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE VersionHistory( time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, author TEXT ); -INSERT INTO VersionHistory (version, author) VALUES (6, 'Initial Creation'); +INSERT INTO VersionHistory (version, author) VALUES (7, 'Initial Creation'); CREATE OR REPLACE FUNCTION update_timestamp_column() @@ -683,4 +683,19 @@ CREATE TABLE past_member_roles( CREATE INDEX member_role_persistence_members ON past_member_roles (guildid, userid); -- }}} +-- Member profile tags {{{ +CREATE TABLE member_profile_tags( + tagid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + tag TEXT NOT NULL, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) +); +-- }}} + +-- Member goals {{{ + +-- }}} + -- vim: set fdm=marker: From c5197257556875a8c42a201806d34875c0ce213d Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 20:49:49 +0200 Subject: [PATCH 05/15] fix (setprofile): Add check for max tasks. --- bot/modules/stats/setprofile.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/modules/stats/setprofile.py b/bot/modules/stats/setprofile.py index 766eb5ea..a3d5c4bb 100644 --- a/bot/modules/stats/setprofile.py +++ b/bot/modules/stats/setprofile.py @@ -12,7 +12,7 @@ from .data import profile_tags from .module import module -MAX_TAGS = 5 +MAX_TAGS = 10 MAX_LENGTH = 30 @@ -33,8 +33,6 @@ async def cmd_setprofile(ctx, flags): Description: Set or update the tags appearing in your study server profile. - You can have at most `5` tags at once. - Moderators can clear a user's tags with `--clear --for @user`. Examples``: {prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe @@ -147,6 +145,9 @@ async def cmd_setprofile(ctx, flags): to_add = [split.strip().upper() for split in ctx.args.split(',')] validate_tag(*to_add) + if len(to_add) > MAX_TAGS: + return await ctx.error_reply(f"You can have a maximum of {MAX_TAGS} tags!") + # Remove the existing badges deleted_rows = profile_tags.delete_where( guildid=ctx.guild.id, @@ -181,7 +182,7 @@ async def cmd_setprofile(ctx, flags): embed = discord.Embed( colour=discord.Colour.red(), description=( - "Use this command to edit your study profile " + "Edit your study profile " "tags so other people can see what you do!" ) ) From 113ff0379aae9edc6bf8e898846844cbf3027ab1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 21:55:25 +0200 Subject: [PATCH 06/15] fix (setprofile): Update tag parsing. --- bot/modules/stats/setprofile.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/modules/stats/setprofile.py b/bot/modules/stats/setprofile.py index a3d5c4bb..8cc147bb 100644 --- a/bot/modules/stats/setprofile.py +++ b/bot/modules/stats/setprofile.py @@ -142,7 +142,8 @@ async def cmd_setprofile(ctx, flags): else: # Assume the arguments are a comma separated list of badges # Parse and validate - to_add = [split.strip().upper() for split in ctx.args.split(',')] + to_add = [split.strip().upper() for line in ctx.args.splitlines() for split in line.split(',')] + to_add = [split.replace('<3', '❤️') for split in to_add if split] validate_tag(*to_add) if len(to_add) > MAX_TAGS: @@ -206,7 +207,7 @@ async def cmd_setprofile(ctx, flags): def validate_tag(*content): for content in content: - if not set(content).issubset(string.printable): + if not set(content.replace('❤️', '')).issubset(string.printable): raise SafeCancellation( f"Invalid tag `{content}`!\n" "Tags may only contain alphanumeric and punctuation characters." From b1bcee8cc6e7ad099c9da3a6879d1a3d78f4d689 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 21:57:32 +0200 Subject: [PATCH 07/15] fix (setprofile): Guard against empty input. --- bot/modules/stats/setprofile.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/modules/stats/setprofile.py b/bot/modules/stats/setprofile.py index 8cc147bb..8eeafffd 100644 --- a/bot/modules/stats/setprofile.py +++ b/bot/modules/stats/setprofile.py @@ -144,6 +144,9 @@ async def cmd_setprofile(ctx, flags): # Parse and validate to_add = [split.strip().upper() for line in ctx.args.splitlines() for split in line.split(',')] to_add = [split.replace('<3', '❤️') for split in to_add if split] + if not to_add: + return await ctx.error_reply("No valid tags given, nothing to do!") + validate_tag(*to_add) if len(to_add) > MAX_TAGS: From fa430b840340eb22f5b359327576d113fd1a1cfa Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 10:57:30 +0200 Subject: [PATCH 08/15] fix (todo): Correctly filter deleted tasks. --- bot/modules/todo/Tasklist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/todo/Tasklist.py b/bot/modules/todo/Tasklist.py index f1b2ebac..3618b734 100644 --- a/bot/modules/todo/Tasklist.py +++ b/bot/modules/todo/Tasklist.py @@ -479,7 +479,7 @@ class Tasklist: count = data.tasklist.select_one_where( select_columns=("COUNT(*)",), userid=self.member.id, - deleted_at=NOTNULL + deleted_at=NULL )[0] # Fetch maximum allowed count From d60a8772a3ff79a137ba939df59345f8aa68c173 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 11:26:58 +0200 Subject: [PATCH 09/15] feature (stats): Weekly and monthly goals. Add a new editing interface for weekly and monthly goals. Textual viewing interface is currently a stub. Add `month_timestamp` and `week_timestamp` lion properties. --- bot/core/lion.py | 31 ++++++++++++++++++ bot/modules/stats/__init__.py | 1 + bot/modules/stats/data.py | 28 ++++++++++++++++- bot/modules/stats/setprofile.py | 8 +++-- data/migration/v6-v7/migration.sql | 50 ++++++++++++++++++++++++++++++ data/schema.sql | 48 ++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 3 deletions(-) diff --git a/bot/core/lion.py b/bot/core/lion.py index c803cff8..4b3baea9 100644 --- a/bot/core/lion.py +++ b/bot/core/lion.py @@ -146,6 +146,37 @@ class Lion: now = datetime.now(tz=self.timezone) return now.replace(hour=0, minute=0, second=0, microsecond=0) + @property + def day_timestamp(self): + """ + EPOCH timestamp representing the current day for the user. + NOTE: This is the timestamp of the start of the current UTC day with the same date as the user's day. + This is *not* the start of the current user's day, either in UTC or their own timezone. + This may also not be the start of the current day in UTC (consider 23:00 for a user in UTC-2). + """ + now = datetime.now(tz=self.timezone) + day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + return int(day_start.replace(tzinfo=pytz.utc).timestamp()) + + @property + def week_timestamp(self): + """ + EPOCH timestamp representing the current week for the user. + """ + now = datetime.now(tz=self.timezone) + day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = day_start - timedelta(days=day_start.weekday()) + return int(week_start.replace(tzinfo=pytz.utc).timestamp()) + + @property + def month_timestamp(self): + """ + EPOCH timestamp representing the current month for the user. + """ + now = datetime.now(tz=self.timezone) + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return int(month_start.replace(tzinfo=pytz.utc).timestamp()) + @property def remaining_in_day(self): return ((self.day_start + timedelta(days=1)) - datetime.now(self.timezone)).total_seconds() diff --git a/bot/modules/stats/__init__.py b/bot/modules/stats/__init__.py index 4478e63d..cf342274 100644 --- a/bot/modules/stats/__init__.py +++ b/bot/modules/stats/__init__.py @@ -4,3 +4,4 @@ from . import data from . import profile from . import setprofile from . import top_cmd +from . import goals diff --git a/bot/modules/stats/data.py b/bot/modules/stats/data.py index 79bcb7a9..234b226c 100644 --- a/bot/modules/stats/data.py +++ b/bot/modules/stats/data.py @@ -1,4 +1,6 @@ -from data import Table +from cachetools import TTLCache + +from data import Table, RowTable profile_tags = Table('member_profile_tags', attach_as='profile_tags') @@ -11,3 +13,27 @@ def get_tags_for(guildid, userid): _extra="ORDER BY tagid ASC" ) return [row['tag'] for row in rows] + + +weekly_goals = RowTable( + 'member_weekly_goals', + ('guildid', 'userid', 'weekid', 'study_goal', 'task_goal'), + ('guildid', 'userid', 'weekid'), + cache=TTLCache(5000, 60 * 60 * 24), + attach_as='weekly_goals' +) + + +# NOTE: Not using a RowTable here since these will almost always be mass-selected +weekly_tasks = Table('member_weekly_goal_tasks') + + +monthly_goals = RowTable( + 'member_monthly_goals', + ('guildid', 'userid', 'monthid', 'study_goal', 'task_goal'), + ('guildid', 'userid', 'monthid'), + cache=TTLCache(5000, 60 * 60 * 24), + attach_as='monthly_goals' +) + +monthly_tasks = Table('member_monthly_goal_tasks') diff --git a/bot/modules/stats/setprofile.py b/bot/modules/stats/setprofile.py index 8eeafffd..9618b388 100644 --- a/bot/modules/stats/setprofile.py +++ b/bot/modules/stats/setprofile.py @@ -167,7 +167,7 @@ async def cmd_setprofile(ctx, flags): # Ack with user embed = discord.Embed( colour=discord.Colour.green(), - description="Profile tags updated!" + title="Profile tags updated!" ) embed.add_field( name="New tags", @@ -175,10 +175,14 @@ async def cmd_setprofile(ctx, flags): ) if deleted_rows: embed.add_field( - name="Previous tags", + name="Replaced tags", value='\n'.join(row['tag'].upper() for row in deleted_rows), inline=False ) + if len(to_add) == 1: + embed.set_footer( + text=f"TIP: Add multiple tags with {ctx.best_prefix}setprofile tag1, tag2, ..." + ) await ctx.reply(embed=embed) else: # No input was provided diff --git a/data/migration/v6-v7/migration.sql b/data/migration/v6-v7/migration.sql index 3d4db031..905f4452 100644 --- a/data/migration/v6-v7/migration.sql +++ b/data/migration/v6-v7/migration.sql @@ -21,6 +21,56 @@ CREATE TABLE member_profile_tags( _timestamp TIMESTAMPTZ DEFAULT now(), FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ); +CREATE INDEX member_profile_tags_members ON member_profile_tags (guildid, userid); +-- New member weekly and monthly goals +CREATE TABLE member_weekly_goals( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + weekid INTEGER NOT NULL, -- Epoch time of the start of the UTC week + study_goal INTEGER, + task_goal INTEGER, + _timestamp TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (guildid, userid, weekid), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_weekly_goals_members ON member_weekly_goals (guildid, userid); + +CREATE TABLE member_weekly_goal_tasks( + taskid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + weekid INTEGER NOT NULL, + content TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (weekid, guildid, userid) REFERENCES member_weekly_goals (weekid, guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_weekly_goal_tasks_members_weekly ON member_weekly_goal_tasks (guildid, userid, weekid); + +CREATE TABLE member_monthly_goals( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + monthid INTEGER NOT NULL, -- Epoch time of the start of the UTC month + study_goal INTEGER, + task_goal INTEGER, + _timestamp TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (guildid, userid, monthid), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_monthly_goals_members ON member_monthly_goals (guildid, userid); + +CREATE TABLE member_monthly_goal_tasks( + taskid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + monthid INTEGER NOT NULL, + content TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (monthid, guildid, userid) REFERENCES member_monthly_goals (monthid, guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_monthly_goal_tasks_members_monthly ON member_monthly_goal_tasks (guildid, userid, monthid); + INSERT INTO VersionHistory (version, author) VALUES (7, 'v6-v7 migration'); diff --git a/data/schema.sql b/data/schema.sql index 5edb3add..92f40c2f 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -692,9 +692,57 @@ CREATE TABLE member_profile_tags( _timestamp TIMESTAMPTZ DEFAULT now(), FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ); +CREATE INDEX member_profile_tags_members ON member_profile_tags (guildid, userid); -- }}} -- Member goals {{{ +CREATE TABLE member_weekly_goals( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + weekid INTEGER NOT NULL, -- Epoch time of the start of the UTC week + study_goal INTEGER, + task_goal INTEGER, + _timestamp TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (guildid, userid, weekid), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_weekly_goals_members ON member_weekly_goals (guildid, userid); + +CREATE TABLE member_weekly_goal_tasks( + taskid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + weekid INTEGER NOT NULL, + content TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (weekid, guildid, userid) REFERENCES member_weekly_goals (weekid, guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_weekly_goal_tasks_members_weekly ON member_weekly_goal_tasks (guildid, userid, weekid); + +CREATE TABLE member_monthly_goals( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + monthid INTEGER NOT NULL, -- Epoch time of the start of the UTC month + study_goal INTEGER, + task_goal INTEGER, + _timestamp TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (guildid, userid, monthid), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_monthly_goals_members ON member_monthly_goals (guildid, userid); + +CREATE TABLE member_monthly_goal_tasks( + taskid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + monthid INTEGER NOT NULL, + content TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (monthid, guildid, userid) REFERENCES member_monthly_goals (monthid, guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_monthly_goal_tasks_members_monthly ON member_monthly_goal_tasks (guildid, userid, monthid); -- }}} From ad723fe6a37bf6fecc1d1aa8699fd69597320dfc Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 13:57:11 +0200 Subject: [PATCH 10/15] feature (utils): Add a ratelimit implementation. --- bot/utils/ratelimits.py | 92 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 bot/utils/ratelimits.py diff --git a/bot/utils/ratelimits.py b/bot/utils/ratelimits.py new file mode 100644 index 00000000..545e5c53 --- /dev/null +++ b/bot/utils/ratelimits.py @@ -0,0 +1,92 @@ +import time +from cmdClient.lib import SafeCancellation + +from cachetools import TTLCache + + +class BucketFull(Exception): + """ + Throw when a requested Bucket is already full + """ + pass + + +class BucketOverFull(BucketFull): + """ + Throw when a requested Bucket is overfull + """ + pass + + +class Bucket: + __slots__ = ('max_level', 'empty_time', 'leak_rate', '_level', '_last_checked', '_last_full') + + def __init__(self, max_level, empty_time): + self.max_level = max_level + self.empty_time = empty_time + self.leak_rate = max_level / empty_time + + self._level = 0 + self._last_checked = time.time() + + self._last_full = False + + @property + def overfull(self): + self._leak() + return self._level > self.max_level + + def _leak(self): + if self._level: + elapsed = time.time() - self._last_checked + self._level = max(0, self._level - (elapsed * self.leak_rate)) + + self._last_checked = time.time() + + def request(self): + self._leak() + if self._level + 1 > self.max_level + 1: + raise BucketOverFull + elif self._level + 1 > self.max_level: + self._level += 1 + if self._last_full: + raise BucketOverFull + else: + self._last_full = True + raise BucketFull + else: + self._last_full = False + self._level += 1 + + +class RateLimit: + def __init__(self, max_level, empty_time, error=None, cache=TTLCache(1000, 60 * 60)): + self.max_level = max_level + self.empty_time = empty_time + + self.error = error or "Too many requests, please slow down!" + self.buckets = cache + + def request_for(self, key): + if not (bucket := self.buckets.get(key, None)): + bucket = self.buckets[key] = Bucket(self.max_level, self.empty_time) + + try: + bucket.request() + except BucketOverFull: + raise SafeCancellation(details="Bucket overflow") + except BucketFull: + raise SafeCancellation(self.error, details="Bucket full") + + def ward(self, member=True, key=None): + """ + Command ratelimit decorator. + """ + key = key or ((lambda ctx: (ctx.guild.id, ctx.author.id)) if member else (lambda ctx: ctx.author.id)) + + def decorator(func): + async def wrapper(ctx, *args, **kwargs): + self.request_for(key(ctx)) + return await func(ctx, *args, **kwargs) + return wrapper + return decorator From e0c8993167830cfe24b14c49a4c59b4bf37baaa4 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 14:01:04 +0200 Subject: [PATCH 11/15] (goals): Add missing goals command. --- bot/modules/stats/goals.py | 326 +++++++++++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 bot/modules/stats/goals.py diff --git a/bot/modules/stats/goals.py b/bot/modules/stats/goals.py new file mode 100644 index 00000000..68ee4897 --- /dev/null +++ b/bot/modules/stats/goals.py @@ -0,0 +1,326 @@ +""" +Weekly and Monthly goal display and edit interface. +""" +from enum import Enum +import discord + +from cmdClient.checks import in_guild +from cmdClient.lib import SafeCancellation + +from utils.lib import parse_ranges + +from .module import module +from .data import weekly_goals, weekly_tasks, monthly_goals, monthly_tasks + + +MAX_LENGTH = 200 +MAX_TASKS = 5 + + +class GoalType(Enum): + WEEKLY = 0 + MONTHLY = 1 + + +def index_range_parser(userstr, max): + try: + indexes = parse_ranges(userstr) + except SafeCancellation: + raise SafeCancellation( + "Couldn't parse the provided task ids! " + "Please list the task numbers or ranges separated by a comma, e.g. `0, 2-4`." + ) from None + + return [index for index in indexes if index <= max] + + +@module.cmd( + "weeklygoals", + group="Statistics", + desc="Set your weekly goals and view your progress!", + aliases=('weeklygoal',), + flags=('study=', 'tasks=') +) +@in_guild() +async def cmd_weeklygoals(ctx, flags): + """ + Usage``: + {prefix}weeklygoals [--study ] [--tasks ] + {prefix}weeklygoals add + {prefix}weeklygoals edit + {prefix}weeklygoals check + {prefix}weeklygoals remove + Description: + Set yourself up to `5` goals for this week and keep yourself accountable! + Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`. + You can also add multiple tasks at once by writing them on multiple lines. + + You can also track your progress towards a number of hours studied with `--study`, \ + and aim for a number of tasks completed with `--tasks`. + + Run the command with no arguments or check your profile to see your progress! + Examples``: + {prefix}weeklygoals add Read chapters 1 to 10. + {prefix}weeklygoals check 1 + {prefix}weeklygoals --study 48h --tasks 60 + """ + await goals_command(ctx, flags, GoalType.WEEKLY) + + +@module.cmd( + "monthlygoals", + group="Statistics", + desc="Set your monthly goals and view your progress!", + aliases=('monthlygoal',), + flags=('study=', 'tasks=') +) +@in_guild() +async def cmd_monthlygoals(ctx, flags): + """ + Usage``: + {prefix}monthlygoals [--study ] [--tasks ] + {prefix}monthlygoals add + {prefix}monthlygoals edit + {prefix}monthlygoals check + {prefix}monthlygoals uncheck + {prefix}monthlygoals remove + Description: + Set yourself up to `5` goals for this month and keep yourself accountable! + Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`. + You can also add multiple tasks at once by writing them on multiple lines. + + You can also track your progress towards a number of hours studied with `--study`, \ + and aim for a number of tasks completed with `--tasks`. + + Run the command with no arguments or check your profile to see your progress! + Examples``: + {prefix}monthlygoals add Read chapters 1 to 10. + {prefix}monthlygoals check 1 + {prefix}monthlygoals --study 180h --tasks 60 + """ + await goals_command(ctx, flags, GoalType.MONTHLY) + + +async def goals_command(ctx, flags, goal_type): + prefix = ctx.best_prefix + if goal_type == GoalType.WEEKLY: + name = 'week' + goal_table = weekly_goals + task_table = weekly_tasks + rowkey = 'weekid' + rowid = ctx.alion.week_timestamp + + tasklist = task_table.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + weekid=rowid + ) + + max_time = 7 * 16 + else: + name = 'month' + goal_table = monthly_goals + task_table = monthly_tasks + rowid = ctx.alion.month_timestamp + rowkey = 'monthid' + + tasklist = task_table.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + monthid=rowid + ) + + max_time = 31 * 16 + + # We ensured the `lion` existed with `ctx.alion` above + # This also ensures a new tasklist can reference the period member goal key + # TODO: Should creation copy the previous existing week? + goal_row = goal_table.fetch_or_create((ctx.guild.id, ctx.author.id, rowid)) + + if flags['study']: + # Set study hour goal + time = flags['study'].lower().strip('h ') + if not time or not time.isdigit(): + return await ctx.error_reply( + f"Please provide your {name}ly study goal in hours!\n" + f"For example, `{prefix}{ctx.alias} --study 48h`" + ) + hours = int(time) + if hours > max_time: + return await ctx.error_reply( + "You can't set your goal this high! Please rest and keep a healthy lifestyle." + ) + + goal_row.study_goal = hours + + if flags['tasks']: + # Set tasks completed goal + count = flags['tasks'] + if not count or not count.isdigit(): + return await ctx.error_reply( + f"Please provide the number of tasks you want to complete this {name}!\n" + f"For example, `{prefix}{ctx.alias} --tasks 300`" + ) + goal_row.task_goal = int(count) + + if ctx.args: + # If there are arguments, assume task/goal management + # Extract the command if it exists, assume add operation if it doesn't + splits = ctx.args.split(maxsplit=1) + cmd = splits[0].lower().strip() + args = splits[1].strip() if len(splits) > 1 else '' + + if cmd in ('check', 'done', 'complete'): + if not args: + # Show subcommand usage + return await ctx.error_reply( + f"**Usage:**`{prefix}{ctx.alias} check `\n" + f"**Example:**`{prefix}{ctx.alias} check 0, 2-4`" + ) + if (indexes := index_range_parser(args, len(tasklist) - 1)): + # Check the given indexes + # If there are no valid indexes given, just do nothing and fall out to showing the goals + task_table.update_where( + {'completed': True}, + taskid=[tasklist[index]['taskid'] for index in indexes] + ) + elif cmd in ('uncheck', 'undone', 'uncomplete'): + if not args: + # Show subcommand usage + return await ctx.error_reply( + f"**Usage:**`{prefix}{ctx.alias} uncheck `\n" + f"**Example:**`{prefix}{ctx.alias} uncheck 0, 2-4`" + ) + if (indexes := index_range_parser(args, len(tasklist) - 1)): + # Check the given indexes + # If there are no valid indexes given, just do nothing and fall out to showing the goals + task_table.update_where( + {'completed': False}, + taskid=[tasklist[index]['taskid'] for index in indexes] + ) + elif cmd in ('remove', 'delete', '-', 'rm'): + if not args: + # Show subcommand usage + return await ctx.error_reply( + f"**Usage:**`{prefix}{ctx.alias} remove `\n" + f"**Example:**`{prefix}{ctx.alias} remove 0, 2-4`" + ) + if (indexes := index_range_parser(args, len(tasklist) - 1)): + # Delete the given indexes + # If there are no valid indexes given, just do nothing and fall out to showing the goals + task_table.delete_where( + taskid=[tasklist[index]['taskid'] for index in indexes] + ) + elif cmd == 'edit': + if not args or len(splits := args.split(maxsplit=1)) < 2 or not splits[0].isdigit(): + # Show subcommand usage + return await ctx.error_reply( + f"**Usage:**`{prefix}{ctx.alias} edit `\n" + f"**Example:**`{prefix}{ctx.alias} edit 2 Fix the scond task`" + ) + index = int(splits[0]) + new_content = splits[1].strip() + + if index >= len(tasklist): + return await ctx.error_reply( + f"Task `{index}` doesn't exist to edit!" + ) + + if len(new_content) > MAX_LENGTH: + return await ctx.error_reply( + f"Please keep your goals under `{MAX_LENGTH}` characters long." + ) + + # Passed all checks, edit task + task_table.update_were( + {'content': new_content}, + taskid=tasklist[index]['taskid'] + ) + else: + # Extract the tasks to add + if cmd in ('add', '+'): + if not args: + # Show subcommand usage + return await ctx.error_reply( + f"**Usage:**`{prefix}{ctx.alias} [add] `\n" + f"**Example:**`{prefix}{ctx.alias} add Read the Studylion help pages.`" + ) + else: + args = ctx.args + tasks = args.splitlines() + + # Check count + if len(tasklist) + len(tasks) > MAX_TASKS: + return await ctx.error_reply( + f"You can have at most **{MAX_TASKS}** {name}ly goals!" + ) + + # Check length + if any(len(task) > MAX_LENGTH for task in tasks): + return await ctx.error_reply( + f"Please keep your goals under `{MAX_LENGTH}` characters long." + ) + + # We passed the checks, add the tasks + to_insert = [ + (ctx.guild.id, ctx.author.id, rowid, task) + for task in tasks + ] + task_table.insert_many( + *to_insert, + insert_keys=('guildid', 'userid', rowkey, 'content') + ) + elif not any((goal_row.study_goal, goal_row.task_goal, tasklist)): + # The user hasn't set any goals for this time period + # Prompt them with information about how to set a goal + embed = discord.Embed( + colour=discord.Colour.orange(), + title=f"**You haven't set any goals for this {name} yet! Try the following:**\n" + ) + embed.add_field( + name="Aim for a number of study hours with", + value=f"`{prefix}{ctx.alias} --study 48h`" + ) + embed.add_field( + name="Aim for a number of tasks completed with", + value=f"`{prefix}{ctx.alias} --tasks 300`", + inline=False + ) + embed.add_field( + name=f"Set up to 5 custom goals for the {name}!", + value=( + f"`{prefix}{ctx.alias} add Write a 200 page thesis.`\n" + f"`{prefix}{ctx.alias} edit 1 Write 2 pages of the 200 page thesis.`\n" + f"`{prefix}{ctx.alias} done 0, 1, 3-4`\n" + f"`{prefix}{ctx.alias} delete 2-4`" + ), + inline=False + ) + return await ctx.reply(embed=embed) + + # Show the goals + if goal_type == GoalType.WEEKLY: + await display_weekly_goals_for(ctx) + else: + await display_monthly_goals_for(ctx) + + +async def display_weekly_goals_for(ctx): + """ + Display the user's weekly goal summary and progress towards them + TODO: Currently a stub, since the system is overidden by the GUI plugin + """ + # Collect data + lion = ctx.alion + rowid = lion.week_timestamp + goals = weekly_goals.fetch_or_create((ctx.guild.id, ctx.author.id, rowid)) + tasklist = weekly_tasks.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + weekid=rowid + ) + ... + + +async def display_monthly_goals_for(ctx): + ... From 7acf7476a48eeeb9b6ac4eee2b213bd8a40a1b02 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 22:39:23 +0200 Subject: [PATCH 12/15] (goals): Update limits. --- bot/modules/stats/goals.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/modules/stats/goals.py b/bot/modules/stats/goals.py index 68ee4897..39b8c254 100644 --- a/bot/modules/stats/goals.py +++ b/bot/modules/stats/goals.py @@ -14,7 +14,7 @@ from .data import weekly_goals, weekly_tasks, monthly_goals, monthly_tasks MAX_LENGTH = 200 -MAX_TASKS = 5 +MAX_TASKS = 10 class GoalType(Enum): @@ -161,6 +161,10 @@ async def goals_command(ctx, flags, goal_type): f"Please provide the number of tasks you want to complete this {name}!\n" f"For example, `{prefix}{ctx.alias} --tasks 300`" ) + if int(count) > 2048: + return await ctx.error_reply( + "Your task goal is too high!" + ) goal_row.task_goal = int(count) if ctx.args: From d0e987d0b137a6d0e349f4e2cb9c6bc25f16cbbf Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 22:46:16 +0200 Subject: [PATCH 13/15] fix (goals): Order retrieved tasklist. --- bot/modules/stats/goals.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/modules/stats/goals.py b/bot/modules/stats/goals.py index 39b8c254..6cb716f7 100644 --- a/bot/modules/stats/goals.py +++ b/bot/modules/stats/goals.py @@ -113,7 +113,8 @@ async def goals_command(ctx, flags, goal_type): tasklist = task_table.select_where( guildid=ctx.guild.id, userid=ctx.author.id, - weekid=rowid + weekid=rowid, + _extra="ORDER BY taskid ASC" ) max_time = 7 * 16 @@ -127,7 +128,8 @@ async def goals_command(ctx, flags, goal_type): tasklist = task_table.select_where( guildid=ctx.guild.id, userid=ctx.author.id, - monthid=rowid + monthid=rowid, + _extra="ORDER BY taskid ASC" ) max_time = 31 * 16 From 7e3f1a2fbb9e19b4081898f8d8ac38ae8634a8fb Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 22:51:04 +0200 Subject: [PATCH 14/15] fix (goals): Fix reference to old limits. --- bot/modules/stats/goals.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/modules/stats/goals.py b/bot/modules/stats/goals.py index 6cb716f7..a3a03ae8 100644 --- a/bot/modules/stats/goals.py +++ b/bot/modules/stats/goals.py @@ -51,7 +51,7 @@ async def cmd_weeklygoals(ctx, flags): {prefix}weeklygoals check {prefix}weeklygoals remove Description: - Set yourself up to `5` goals for this week and keep yourself accountable! + Set yourself up to `10` goals for this week and keep yourself accountable! Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`. You can also add multiple tasks at once by writing them on multiple lines. @@ -85,7 +85,7 @@ async def cmd_monthlygoals(ctx, flags): {prefix}monthlygoals uncheck {prefix}monthlygoals remove Description: - Set yourself up to `5` goals for this month and keep yourself accountable! + Set yourself up to `10` goals for this month and keep yourself accountable! Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`. You can also add multiple tasks at once by writing them on multiple lines. @@ -293,7 +293,7 @@ async def goals_command(ctx, flags, goal_type): inline=False ) embed.add_field( - name=f"Set up to 5 custom goals for the {name}!", + name=f"Set up to 10 custom goals for the {name}!", value=( f"`{prefix}{ctx.alias} add Write a 200 page thesis.`\n" f"`{prefix}{ctx.alias} edit 1 Write 2 pages of the 200 page thesis.`\n" From 5f3e765b51c5b47bc7f96622f2a484ee25487904 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 23:06:46 +0200 Subject: [PATCH 15/15] fix (goals): Fix typo. --- bot/modules/stats/goals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/stats/goals.py b/bot/modules/stats/goals.py index a3a03ae8..cec8f911 100644 --- a/bot/modules/stats/goals.py +++ b/bot/modules/stats/goals.py @@ -238,7 +238,7 @@ async def goals_command(ctx, flags, goal_type): ) # Passed all checks, edit task - task_table.update_were( + task_table.update_where( {'content': new_content}, taskid=tasklist[index]['taskid'] )