@@ -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()
|
||||||
|
|||||||
@@ -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 *
|
||||||
|
|||||||
7
bot/modules/stats/__init__.py
Normal file
7
bot/modules/stats/__init__.py
Normal 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
39
bot/modules/stats/data.py
Normal 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
332
bot/modules/stats/goals.py
Normal 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):
|
||||||
|
...
|
||||||
4
bot/modules/stats/module.py
Normal file
4
bot/modules/stats/module.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from LionModule import LionModule
|
||||||
|
|
||||||
|
|
||||||
|
module = LionModule("Statistics")
|
||||||
@@ -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
|
||||||
|
|
||||||
225
bot/modules/stats/setprofile.py
Normal file
225
bot/modules/stats/setprofile.py
Normal 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."
|
||||||
|
)
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from LionModule import LionModule
|
from LionModule import LionModule
|
||||||
|
|
||||||
|
|
||||||
module = LionModule("Study_Stats")
|
module = LionModule("Study_Tracking")
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
92
bot/utils/ratelimits.py
Normal 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
|
||||||
76
data/migration/v6-v7/migration.sql
Normal file
76
data/migration/v6-v7/migration.sql
Normal 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');
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user