Merge branch 'staging' of cgithub:StudyLions/StudyLion into staging

This commit is contained in:
2022-01-01 07:07:09 +02:00
16 changed files with 926 additions and 62 deletions

View File

@@ -146,6 +146,37 @@ class Lion:
now = datetime.now(tz=self.timezone) now = datetime.now(tz=self.timezone)
return now.replace(hour=0, minute=0, second=0, microsecond=0) 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 @property
def remaining_in_day(self): def remaining_in_day(self):
return ((self.day_start + timedelta(days=1)) - datetime.now(self.timezone)).total_seconds() return ((self.day_start + timedelta(days=1)) - datetime.now(self.timezone)).total_seconds()

View File

@@ -3,6 +3,7 @@ from .guild_admin import *
from .meta import * from .meta import *
from .economy import * from .economy import *
from .study import * from .study import *
from .stats import *
from .user_config import * from .user_config import *
from .workout import * from .workout import *
from .todo import * from .todo import *

View File

@@ -0,0 +1,7 @@
from .module import module
from . import data
from . import profile
from . import setprofile
from . import top_cmd
from . import goals

39
bot/modules/stats/data.py Normal file
View File

@@ -0,0 +1,39 @@
from cachetools import TTLCache
from data import Table, RowTable
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]
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')

332
bot/modules/stats/goals.py Normal file
View File

@@ -0,0 +1,332 @@
"""
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 = 10
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 <hours>] [--tasks <number>]
{prefix}weeklygoals add <task>
{prefix}weeklygoals edit <taskid> <new task>
{prefix}weeklygoals check <taskids>
{prefix}weeklygoals remove <taskids>
Description:
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.
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 <hours>] [--tasks <number>]
{prefix}monthlygoals add <task>
{prefix}monthlygoals edit <taskid> <new task>
{prefix}monthlygoals check <taskids>
{prefix}monthlygoals uncheck <taskids>
{prefix}monthlygoals remove <taskids>
Description:
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.
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,
_extra="ORDER BY taskid ASC"
)
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,
_extra="ORDER BY taskid ASC"
)
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`"
)
if int(count) > 2048:
return await ctx.error_reply(
"Your task goal is too high!"
)
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 <taskids>`\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 <taskids>`\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 <taskids>`\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 <taskid> <edited task>`\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_where(
{'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] <new task>`\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 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"
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):
...

View File

@@ -0,0 +1,4 @@
from LionModule import LionModule
module = LionModule("Statistics")

View File

@@ -7,7 +7,7 @@ from data import tables
from data.conditions import LEQ from data.conditions import LEQ
from core import Lion from core import Lion
from .tracking.data import session_history from modules.study.tracking.data import session_history
from .module import module from .module import module

View File

@@ -0,0 +1,225 @@
"""
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 = 10
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 <tag>, <tag>, <tag>, ...
{prefix}setprofile <id> <new tag>
{prefix}setprofile --clear [--for @user]
Description:
Set or update the tags appearing in your study server profile.
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 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:
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,
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(),
title="Profile tags updated!"
)
embed.add_field(
name="New tags",
value='\n'.join(to_add)
)
if deleted_rows:
embed.add_field(
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
# Show usage and exit
embed = discord.Embed(
colour=discord.Colour.red(),
description=(
"Edit your study profile "
"tags so other people can see what you do!"
)
)
embed.add_field(
name="Usage",
value=(
f"`{ctx.best_prefix}setprofile <tag>, <tag>, <tag>, ...`\n"
f"`{ctx.best_prefix}setprofile <id> <new tag>`"
)
)
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.replace('❤️', '')).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."
)

View File

@@ -3,6 +3,3 @@ from .module import module
from . import badges from . import badges
from . import timers from . import timers
from . import tracking from . import tracking
from . import top_cmd
from . import stats_cmd

View File

@@ -1,4 +1,4 @@
from LionModule import LionModule from LionModule import LionModule
module = LionModule("Study_Stats") module = LionModule("Study_Tracking")

View File

@@ -6,8 +6,9 @@ import asyncio
from cmdClient.lib import SafeCancellation from cmdClient.lib import SafeCancellation
from meta import client from meta import client
from core import Lion from core import Lion
from data import NULL, NOTNULL
from settings import GuildSettings from settings import GuildSettings
from utils.lib import parse_ranges from utils.lib import parse_ranges, utc_now
from . import data from . import data
# from .module import module # from .module import module
@@ -130,12 +131,12 @@ class Tasklist:
""" """
self.tasklist = data.tasklist.fetch_rows_where( self.tasklist = data.tasklist.fetch_rows_where(
userid=self.member.id, userid=self.member.id,
_extra=("AND last_updated_at > timezone('utc', NOW()) - INTERVAL '24h' " deleted_at=NULL,
"ORDER BY created_at ASC, taskid ASC") _extra="ORDER BY created_at ASC, taskid ASC"
) )
self._refreshed_at = datetime.datetime.utcnow() self._refreshed_at = datetime.datetime.utcnow()
def _format_tasklist(self): async def _format_tasklist(self):
""" """
Generates a sequence of pages from the tasklist Generates a sequence of pages from the tasklist
""" """
@@ -144,7 +145,7 @@ class Tasklist:
"{num:>{numlen}}. [{mark}] {content}".format( "{num:>{numlen}}. [{mark}] {content}".format(
num=i, num=i,
numlen=((self.block_size * (i // self.block_size + 1) - 1) // 10) + 1, 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 content=task.content
) )
for i, task in enumerate(self.tasklist) for i, task in enumerate(self.tasklist)
@@ -159,7 +160,7 @@ class Tasklist:
# Formatting strings and data # Formatting strings and data
page_count = len(task_blocks) or 1 page_count = len(task_blocks) or 1
task_count = len(task_strings) 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: if task_count > 0:
title = "TODO list ({}/{} complete)".format( title = "TODO list ({}/{} complete)".format(
@@ -176,7 +177,7 @@ class Tasklist:
hint = "Type `add <task>` to start adding tasks! E.g. `add Revise Maths Paper 1`." hint = "Type `add <task>` to start adding tasks! E.g. `add Revise Maths Paper 1`."
task_blocks = [""] # Empty page so we can post 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 = [] pages = []
for i, block in enumerate(task_blocks): for i, block in enumerate(task_blocks):
embed = discord.Embed( embed = discord.Embed(
@@ -205,7 +206,7 @@ class Tasklist:
# Calculate or adjust the current page number # Calculate or adjust the current page number
if self.current_page is None: if self.current_page is None:
# First page with incomplete task, or the first page # 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 self.current_page = first_incomplete // self.block_size
elif self.current_page >= len(self.pages): elif self.current_page >= len(self.pages):
self.current_page = len(self.pages) - 1 self.current_page = len(self.pages) - 1
@@ -233,6 +234,12 @@ class Tasklist:
self.message = message self.message = message
self.messages[message.id] = self 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): async def update(self, repost=None):
""" """
Update the displayed tasklist. Update the displayed tasklist.
@@ -243,7 +250,7 @@ class Tasklist:
# Update data and make page list # Update data and make page list
self._refresh() self._refresh()
self._format_tasklist() await self._format_tasklist()
self._adjust_current_page() self._adjust_current_page()
if self.message and not repost: if self.message and not repost:
@@ -266,7 +273,8 @@ class Tasklist:
if not repost: if not repost:
try: 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 # Add or remove paging reactions as required
should_have_paging = len(self.pages) > 1 should_have_paging = len(self.pages) > 1
@@ -387,8 +395,14 @@ class Tasklist:
Delete tasks from the task list Delete tasks from the task list
""" """
taskids = [self.tasklist[i].taskid for i in indexes] 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): def _edit_task(self, index, new_content):
@@ -396,10 +410,12 @@ class Tasklist:
Update the provided task with the new content Update the provided task with the new content
""" """
taskid = self.tasklist[index].taskid taskid = self.tasklist[index].taskid
now = utc_now()
return data.tasklist.update_where( return data.tasklist.update_where(
{ {
'content': new_content, 'content': new_content,
'last_updated_at': datetime.datetime.utcnow() 'last_updated_at': now
}, },
taskid=taskid, taskid=taskid,
) )
@@ -409,13 +425,15 @@ class Tasklist:
Mark provided tasks as complete Mark provided tasks as complete
""" """
taskids = [self.tasklist[i].taskid for i in indexes] taskids = [self.tasklist[i].taskid for i in indexes]
now = utc_now()
return data.tasklist.update_where( return data.tasklist.update_where(
{ {
'complete': True, 'completed_at': now,
'last_updated_at': datetime.datetime.utcnow() 'last_updated_at': now
}, },
taskid=taskids, taskid=taskids,
complete=False, completed_at=NULL,
) )
def _uncheck_tasks(self, *indexes): def _uncheck_tasks(self, *indexes):
@@ -423,13 +441,15 @@ class Tasklist:
Mark provided tasks as incomplete Mark provided tasks as incomplete
""" """
taskids = [self.tasklist[i].taskid for i in indexes] taskids = [self.tasklist[i].taskid for i in indexes]
now = utc_now()
return data.tasklist.update_where( return data.tasklist.update_where(
{ {
'complete': False, 'completed_at': None,
'last_updated_at': datetime.datetime.utcnow() 'last_updated_at': now
}, },
taskid=taskids, taskid=taskids,
complete=True, completed_at=NOTNULL,
) )
def _index_range_parser(self, userstr): def _index_range_parser(self, userstr):
@@ -459,7 +479,7 @@ class Tasklist:
count = data.tasklist.select_one_where( count = data.tasklist.select_one_where(
select_columns=("COUNT(*)",), select_columns=("COUNT(*)",),
userid=self.member.id, userid=self.member.id,
_extra="AND last_updated_at > timezone('utc', NOW()) - INTERVAL '24h'" deleted_at=NULL
)[0] )[0]
# Fetch maximum allowed count # Fetch maximum allowed count
@@ -496,8 +516,8 @@ class Tasklist:
# Parse provided ranges # Parse provided ranges
indexes = self._index_range_parser(userstr) indexes = self._index_range_parser(userstr)
to_check = [index for index in indexes if not 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].complete] to_uncheck = [index for index in indexes if self.tasklist[index].completed_at]
if to_uncheck: if to_uncheck:
self._uncheck_tasks(*to_uncheck) self._uncheck_tasks(*to_uncheck)
@@ -572,21 +592,21 @@ class Tasklist:
self.current_page %= len(self.pages) self.current_page %= len(self.pages)
if self.show_help: if self.show_help:
self.show_help = False self.show_help = False
self._format_tasklist() await self._format_tasklist()
await self.message.edit(embed=self.pages[self.current_page]) await self._update()
elif str_emoji == self.prev_emoji and user.id == self.member.id: elif str_emoji == self.prev_emoji and user.id == self.member.id:
self.current_page -= 1 self.current_page -= 1
self.current_page %= len(self.pages) self.current_page %= len(self.pages)
if self.show_help: if self.show_help:
self.show_help = False self.show_help = False
self._format_tasklist() await self._format_tasklist()
await self.message.edit(embed=self.pages[self.current_page]) await self._update()
elif str_emoji == self.cancel_emoji and user.id == self.member.id: elif str_emoji == self.cancel_emoji and user.id == self.member.id:
await self.deactivate(delete=True) await self.deactivate(delete=True)
elif str_emoji == self.question_emoji and user.id == self.member.id: elif str_emoji == self.question_emoji and user.id == self.member.id:
self.show_help = not self.show_help self.show_help = not self.show_help
self._format_tasklist() await self._format_tasklist()
await self.message.edit(embed=self.pages[self.current_page]) await self._update()
elif str_emoji == self.refresh_emoji and user.id == self.member.id: elif str_emoji == self.refresh_emoji and user.id == self.member.id:
await self.update() await self.update()
@@ -687,15 +707,3 @@ async def tasklist_message_handler(client, message):
async def tasklist_reaction_add_handler(client, reaction, user): async def tasklist_reaction_add_handler(client, reaction, user):
if user != client.user and reaction.message.id in Tasklist.messages: if user != client.user and reaction.message.id in Tasklist.messages:
await Tasklist.messages[reaction.message.id].handle_reaction(reaction, user, True) 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
)

View File

@@ -2,23 +2,11 @@ from data import RowTable, Table
tasklist = RowTable( tasklist = RowTable(
'tasklist', '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' '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_channels = Table('tasklist_channels')
tasklist_rewards = Table('tasklist_reward_history') tasklist_rewards = Table('tasklist_reward_history')

92
bot/utils/ratelimits.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,76 @@
-- Improved tasklist statistics
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;
-- 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)
);
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');

View File

@@ -4,7 +4,7 @@ CREATE TABLE VersionHistory(
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
author TEXT 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() CREATE OR REPLACE FUNCTION update_timestamp_column()
@@ -135,10 +135,11 @@ CREATE TABLE tasklist(
taskid SERIAL PRIMARY KEY, taskid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL, userid BIGINT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
complete BOOL DEFAULT FALSE,
rewarded BOOL DEFAULT FALSE, rewarded BOOL DEFAULT FALSE,
created_at TIMESTAMP DEFAULT (now() at time zone 'utc'), deleted_at TIMESTAMPTZ,
last_updated_at TIMESTAMP DEFAULT (now() at time zone 'utc') completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ,
last_updated_at TIMESTAMPTZ
); );
CREATE INDEX tasklist_users ON tasklist (userid); CREATE INDEX tasklist_users ON tasklist (userid);
@@ -682,4 +683,67 @@ CREATE TABLE past_member_roles(
CREATE INDEX member_role_persistence_members ON past_member_roles (guildid, userid); 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)
);
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);
-- }}}
-- vim: set fdm=marker: -- vim: set fdm=marker: