Merge pull request #15 from StudyLions/improved-stats

Improved stats
This commit is contained in:
Interitio
2022-01-01 07:04:18 +02:00
committed by GitHub
16 changed files with 926 additions and 62 deletions

View File

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

View File

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

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 core import Lion
from .tracking.data import session_history
from modules.study.tracking.data import session_history
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 timers
from . import tracking
from . import top_cmd
from . import stats_cmd

View File

@@ -1,4 +1,4 @@
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 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,12 +131,12 @@ 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()
def _format_tasklist(self):
async def _format_tasklist(self):
"""
Generates a sequence of pages from the tasklist
"""
@@ -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(
@@ -176,7 +177,7 @@ class Tasklist:
hint = "Type `add <task>` 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(
@@ -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
@@ -233,6 +234,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 +250,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 +273,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
@@ -387,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):
@@ -396,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,
)
@@ -409,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):
@@ -423,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):
@@ -459,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=NULL
)[0]
# Fetch maximum allowed count
@@ -496,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)
@@ -572,21 +592,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()
@@ -687,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
)

View File

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

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,
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()
@@ -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);
@@ -682,4 +683,67 @@ 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)
);
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: