rewrite: Move GUI cards into main repo.
This commit is contained in:
320
src/modules/pending-rewrite/gui-commands/goals.py
Normal file
320
src/modules/pending-rewrite/gui-commands/goals.py
Normal file
@@ -0,0 +1,320 @@
|
||||
from datetime import timedelta
|
||||
import asyncio
|
||||
|
||||
from data.conditions import GEQ
|
||||
|
||||
from modules.stats import goals
|
||||
|
||||
from ..module import module, ratelimit
|
||||
|
||||
from ...cards import WeeklyGoalCard, MonthlyGoalCard
|
||||
from ...cards import WeeklyStatsCard, MonthlyStatsCard
|
||||
from ...utils import get_avatar_key, image_as_file
|
||||
|
||||
|
||||
async def _get_weekly_goals(ctx):
|
||||
# Fetch goal data
|
||||
goal_row = ctx.client.data.weekly_goals.fetch_or_create(
|
||||
(ctx.guild.id, ctx.author.id, ctx.alion.week_timestamp)
|
||||
)
|
||||
tasklist_rows = ctx.client.data.member_weekly_goal_tasks.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id,
|
||||
weekid=ctx.alion.week_timestamp,
|
||||
_extra="ORDER BY taskid ASC"
|
||||
)
|
||||
tasklist = [
|
||||
(i, task['content'], task['completed'])
|
||||
for i, task in enumerate(tasklist_rows)
|
||||
]
|
||||
|
||||
day_start = ctx.alion.day_start
|
||||
week_start = day_start - timedelta(days=day_start.weekday())
|
||||
|
||||
# Fetch study data
|
||||
week_study_time = ctx.client.data.session_history.queries.study_time_since(
|
||||
ctx.guild.id, ctx.author.id, week_start
|
||||
)
|
||||
study_hours = week_study_time // 3600
|
||||
|
||||
# Fetch task data
|
||||
tasks_done = ctx.client.data.tasklist.select_one_where(
|
||||
userid=ctx.author.id,
|
||||
completed_at=GEQ(week_start),
|
||||
select_columns=('COUNT(*)',)
|
||||
)[0]
|
||||
|
||||
# Fetch accountability data
|
||||
accountability = ctx.client.data.accountability_member_info.select_where(
|
||||
userid=ctx.author.id,
|
||||
start_at=GEQ(week_start),
|
||||
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
|
||||
)
|
||||
if len(accountability):
|
||||
acc_attended = sum(row['attended'] for row in accountability)
|
||||
acc_total = len(accountability)
|
||||
acc_rate = acc_attended / acc_total
|
||||
else:
|
||||
acc_rate = None
|
||||
|
||||
goalpage = await WeeklyGoalCard.request(
|
||||
name=ctx.author.name,
|
||||
discrim=f"#{ctx.author.discriminator}",
|
||||
avatar=get_avatar_key(ctx.client, ctx.author.id),
|
||||
badges=ctx.alion.profile_tags,
|
||||
tasks_done=tasks_done,
|
||||
studied_hours=study_hours,
|
||||
attendance=acc_rate,
|
||||
tasks_goal=goal_row.task_goal,
|
||||
studied_goal=goal_row.study_goal,
|
||||
goals=tasklist,
|
||||
date=ctx.alion.day_start,
|
||||
skin=WeeklyGoalCard.skin_args_for(ctx)
|
||||
)
|
||||
return goalpage
|
||||
|
||||
|
||||
@ratelimit.ward()
|
||||
async def show_weekly_goals(ctx):
|
||||
image = await _get_weekly_goals(ctx)
|
||||
await ctx.reply(file=image_as_file(image, 'weekly_stats_1.png'))
|
||||
|
||||
goals.display_weekly_goals_for = show_weekly_goals
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"weekly",
|
||||
group="Statistics",
|
||||
desc="View your weekly study statistics!"
|
||||
)
|
||||
@ratelimit.ward()
|
||||
async def cmd_weekly(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}weekly
|
||||
Description:
|
||||
View your weekly study profile.
|
||||
See `{prefix}weeklygoals` to edit your goals!
|
||||
"""
|
||||
day_start = ctx.alion.day_start
|
||||
last_week_start = day_start - timedelta(days=7 + day_start.weekday())
|
||||
|
||||
history = ctx.client.data.session_history.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id,
|
||||
start_time=GEQ(last_week_start - timedelta(days=1)),
|
||||
select_columns=(
|
||||
"start_time",
|
||||
"(start_time + duration * interval '1 second') AS end_time"
|
||||
),
|
||||
_extra="ORDER BY start_time ASC"
|
||||
)
|
||||
timezone = ctx.alion.timezone
|
||||
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
|
||||
|
||||
page_1_task = asyncio.create_task(_get_weekly_goals(ctx))
|
||||
page_2_task = asyncio.create_task(
|
||||
WeeklyStatsCard.request(
|
||||
ctx.author.name,
|
||||
f"#{ctx.author.discriminator}",
|
||||
sessions,
|
||||
day_start,
|
||||
skin=WeeklyStatsCard.skin_args_for(ctx)
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(page_1_task, page_2_task)
|
||||
page_1 = page_1_task.result()
|
||||
page_2 = page_2_task.result()
|
||||
|
||||
await ctx.reply(
|
||||
files=[
|
||||
image_as_file(page_1, "weekly_stats_1.png"),
|
||||
image_as_file(page_2, "weekly_stats_2.png")
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def _get_monthly_goals(ctx):
|
||||
# Fetch goal data
|
||||
goal_row = ctx.client.data.monthly_goals.fetch_or_create(
|
||||
(ctx.guild.id, ctx.author.id, ctx.alion.month_timestamp)
|
||||
)
|
||||
tasklist_rows = ctx.client.data.member_monthly_goal_tasks.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id,
|
||||
monthid=ctx.alion.month_timestamp,
|
||||
_extra="ORDER BY taskid ASC"
|
||||
)
|
||||
tasklist = [
|
||||
(i, task['content'], task['completed'])
|
||||
for i, task in enumerate(tasklist_rows)
|
||||
]
|
||||
|
||||
day_start = ctx.alion.day_start
|
||||
month_start = day_start.replace(day=1)
|
||||
|
||||
# Fetch study data
|
||||
month_study_time = ctx.client.data.session_history.queries.study_time_since(
|
||||
ctx.guild.id, ctx.author.id, month_start
|
||||
)
|
||||
study_hours = month_study_time // 3600
|
||||
|
||||
# Fetch task data
|
||||
tasks_done = ctx.client.data.tasklist.select_one_where(
|
||||
userid=ctx.author.id,
|
||||
completed_at=GEQ(month_start),
|
||||
select_columns=('COUNT(*)',)
|
||||
)[0]
|
||||
|
||||
# Fetch accountability data
|
||||
accountability = ctx.client.data.accountability_member_info.select_where(
|
||||
userid=ctx.author.id,
|
||||
start_at=GEQ(month_start),
|
||||
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
|
||||
)
|
||||
if len(accountability):
|
||||
acc_attended = sum(row['attended'] for row in accountability)
|
||||
acc_total = len(accountability)
|
||||
acc_rate = acc_attended / acc_total
|
||||
else:
|
||||
acc_rate = None
|
||||
|
||||
goalpage = await MonthlyGoalCard.request(
|
||||
name=ctx.author.name,
|
||||
discrim=f"#{ctx.author.discriminator}",
|
||||
avatar=get_avatar_key(ctx.client, ctx.author.id),
|
||||
badges=ctx.alion.profile_tags,
|
||||
tasks_done=tasks_done,
|
||||
studied_hours=study_hours,
|
||||
attendance=acc_rate,
|
||||
tasks_goal=goal_row.task_goal,
|
||||
studied_goal=goal_row.study_goal,
|
||||
goals=tasklist,
|
||||
date=ctx.alion.day_start,
|
||||
skin=MonthlyGoalCard.skin_args_for(ctx)
|
||||
)
|
||||
return goalpage
|
||||
|
||||
|
||||
@ratelimit.ward()
|
||||
async def show_monthly_goals(ctx):
|
||||
image = await _get_monthly_goals(ctx)
|
||||
await ctx.reply(file=image_as_file(image, 'monthly_stats_1.png'))
|
||||
|
||||
goals.display_monthly_goals_for = show_monthly_goals
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"monthly",
|
||||
group="Statistics",
|
||||
desc="View your monthly study statistics!"
|
||||
)
|
||||
async def cmd_monthly(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}monthly
|
||||
Description:
|
||||
View your monthly study profile.
|
||||
See `{prefix}monthlygoals` to edit your goals!
|
||||
"""
|
||||
day_start = ctx.alion.day_start
|
||||
period_start = day_start - timedelta(days=31*4)
|
||||
|
||||
history = ctx.client.data.session_history.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id,
|
||||
select_columns=(
|
||||
"start_time",
|
||||
"(start_time + duration * interval '1 second') AS end_time"
|
||||
),
|
||||
_extra="ORDER BY start_time DESC"
|
||||
)
|
||||
timezone = ctx.alion.timezone
|
||||
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
|
||||
if not sessions:
|
||||
return await ctx.error_reply(
|
||||
"No statistics to show, because you have never studied in this server before!"
|
||||
)
|
||||
|
||||
# Streak statistics
|
||||
streak = 0
|
||||
current_streak = None
|
||||
max_streak = 0
|
||||
|
||||
day_attended = True if 'sessions' in ctx.client.objects and ctx.alion.session else None
|
||||
date = day_start
|
||||
daydiff = timedelta(days=1)
|
||||
|
||||
periods = sessions
|
||||
|
||||
i = 0
|
||||
while i < len(periods):
|
||||
row = periods[i]
|
||||
i += 1
|
||||
if row[1] > date:
|
||||
# They attended this day
|
||||
day_attended = True
|
||||
continue
|
||||
elif day_attended is None:
|
||||
# Didn't attend today, but don't break streak
|
||||
day_attended = False
|
||||
date -= daydiff
|
||||
i -= 1
|
||||
continue
|
||||
elif not day_attended:
|
||||
# Didn't attend the day, streak broken
|
||||
date -= daydiff
|
||||
i -= 1
|
||||
pass
|
||||
else:
|
||||
# Attended the day
|
||||
streak += 1
|
||||
|
||||
# Move window to the previous day and try the row again
|
||||
day_attended = False
|
||||
prev_date = date
|
||||
date -= daydiff
|
||||
i -= 1
|
||||
|
||||
# Special case, when the last session started in the previous day
|
||||
# Then the day is already attended
|
||||
if i > 1 and date < periods[i-2][0] <= prev_date:
|
||||
day_attended = True
|
||||
|
||||
continue
|
||||
|
||||
max_streak = max(max_streak, streak)
|
||||
if current_streak is None:
|
||||
current_streak = streak
|
||||
streak = 0
|
||||
|
||||
# Handle loop exit state, i.e. the last streak
|
||||
if day_attended:
|
||||
streak += 1
|
||||
max_streak = max(max_streak, streak)
|
||||
if current_streak is None:
|
||||
current_streak = streak
|
||||
|
||||
first_session_start = sessions[-1][0]
|
||||
sessions = [session for session in sessions if session[1] > period_start]
|
||||
page_1_task = asyncio.create_task(_get_monthly_goals(ctx))
|
||||
page_2_task = asyncio.create_task(MonthlyStatsCard.request(
|
||||
ctx.author.name,
|
||||
f"#{ctx.author.discriminator}",
|
||||
sessions,
|
||||
day_start.date(),
|
||||
current_streak or 0,
|
||||
max_streak or 0,
|
||||
first_session_start,
|
||||
skin=MonthlyStatsCard.skin_args_for(ctx)
|
||||
))
|
||||
await asyncio.gather(page_1_task, page_2_task)
|
||||
page_1 = page_1_task.result()
|
||||
page_2 = page_2_task.result()
|
||||
await ctx.reply(
|
||||
files=[
|
||||
image_as_file(page_1, "monthly_stats_1.png"),
|
||||
image_as_file(page_2, "monthly_stats_2.png")
|
||||
]
|
||||
)
|
||||
198
src/modules/pending-rewrite/gui-commands/leaderboard.py
Normal file
198
src/modules/pending-rewrite/gui-commands/leaderboard.py
Normal file
@@ -0,0 +1,198 @@
|
||||
import gc
|
||||
import asyncio
|
||||
import discord
|
||||
from cmdClient.checks import in_guild
|
||||
|
||||
import data
|
||||
from data import tables
|
||||
from utils.interactive import discord_shield
|
||||
from meta import conf
|
||||
|
||||
from ...cards import LeaderboardCard
|
||||
from ...utils import image_as_file, edit_files, get_avatar_key
|
||||
|
||||
from ..module import module, ratelimit
|
||||
|
||||
|
||||
next_emoji = conf.emojis.forward
|
||||
my_emoji = conf.emojis.person
|
||||
prev_emoji = conf.emojis.backward
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"top",
|
||||
desc="View the Study Time leaderboard.",
|
||||
group="Statistics",
|
||||
aliases=('ttop', 'toptime', 'top100')
|
||||
)
|
||||
@in_guild()
|
||||
@ratelimit.ward(member=False)
|
||||
async def cmd_top(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}top
|
||||
{prefix}top100
|
||||
Description:
|
||||
Display the study time leaderboard, or the top 100.
|
||||
"""
|
||||
# Handle args
|
||||
if ctx.args and not ctx.args == "100":
|
||||
return await ctx.error_reply(
|
||||
"**Usage:**`{prefix}top` or `{prefix}top100`.".format(prefix=ctx.best_prefix)
|
||||
)
|
||||
top100 = (ctx.args == "100" or ctx.alias == "top100")
|
||||
|
||||
# Fetch the leaderboard
|
||||
exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members)
|
||||
exclude.update(ctx.client.user_blacklist())
|
||||
exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id])
|
||||
|
||||
args = {
|
||||
'guildid': ctx.guild.id,
|
||||
'select_columns': ('userid', 'total_tracked_time::INTEGER', 'display_name'),
|
||||
'_extra': "AND total_tracked_time > 0 ORDER BY total_tracked_time DESC " + ("LIMIT 100" if top100 else "")
|
||||
}
|
||||
if exclude:
|
||||
args['userid'] = data.NOT(list(exclude))
|
||||
|
||||
user_data = tables.members_totals.select_where(**args)
|
||||
|
||||
# Quit early if the leaderboard is empty
|
||||
if not user_data:
|
||||
return await ctx.reply("No leaderboard entries yet!")
|
||||
|
||||
# Extract entries
|
||||
author_rank = None
|
||||
entries = []
|
||||
for i, (userid, time, display_name) in enumerate(user_data):
|
||||
if (member := ctx.guild.get_member(userid)):
|
||||
name = member.display_name
|
||||
elif display_name:
|
||||
name = display_name
|
||||
else:
|
||||
name = str(userid)
|
||||
|
||||
entries.append(
|
||||
(userid, i + 1, time, name, get_avatar_key(ctx.client, userid))
|
||||
)
|
||||
|
||||
if ctx.author.id == userid:
|
||||
author_rank = i + 1
|
||||
|
||||
# Break into pages
|
||||
entry_pages = [entries[i:i+10] for i in range(0, len(entries), 10)]
|
||||
page_count = len(entry_pages)
|
||||
author_page = (author_rank - 1) // 10 if author_rank is not None else None
|
||||
|
||||
if page_count == 1:
|
||||
image = await LeaderboardCard.request(
|
||||
ctx.guild.name,
|
||||
entries=entry_pages[0],
|
||||
highlight=author_rank,
|
||||
skin=LeaderboardCard.skin_args_for(ctx)
|
||||
)
|
||||
_file = image_as_file(image, "leaderboard.png")
|
||||
await ctx.reply(file=_file)
|
||||
del image
|
||||
else:
|
||||
page_i = 0
|
||||
|
||||
page_futures = {}
|
||||
|
||||
def submit_page_request(i):
|
||||
if (_existing := page_futures.get(i, None)) is not None:
|
||||
# A future was already submitted
|
||||
_future = _existing
|
||||
else:
|
||||
_future = asyncio.create_task(
|
||||
LeaderboardCard.request(
|
||||
ctx.guild.name,
|
||||
entries=entry_pages[i % page_count],
|
||||
highlight=author_rank,
|
||||
skin=LeaderboardCard.skin_args_for(ctx)
|
||||
)
|
||||
)
|
||||
page_futures[i] = _future
|
||||
return _future
|
||||
|
||||
# Draw first page
|
||||
out_msg = await ctx.reply(file=image_as_file(await submit_page_request(0), "leaderboard.png"))
|
||||
|
||||
# Prefetch likely next page
|
||||
submit_page_request(author_page or 1)
|
||||
|
||||
# Add reactions
|
||||
try:
|
||||
await out_msg.add_reaction(prev_emoji)
|
||||
if author_page is not None:
|
||||
await out_msg.add_reaction(my_emoji)
|
||||
await out_msg.add_reaction(next_emoji)
|
||||
except discord.Forbidden:
|
||||
perms = ctx.ch.permissions_for(ctx.guild.me)
|
||||
if not perms.add_reactions:
|
||||
await ctx.error_reply(
|
||||
"Cannot page leaderboard because I do not have the `add_reactions` permission!"
|
||||
)
|
||||
elif not perms.read_message_history:
|
||||
await ctx.error_reply(
|
||||
"Cannot page leaderboard because I do not have the `read_message_history` permission!"
|
||||
)
|
||||
else:
|
||||
await ctx.error_reply(
|
||||
"Cannot page leaderboard due to insufficient permissions!"
|
||||
)
|
||||
return
|
||||
|
||||
def reaction_check(reaction, user):
|
||||
result = reaction.message.id == out_msg.id
|
||||
result = result and reaction.emoji in [next_emoji, my_emoji, prev_emoji]
|
||||
result = result and not (user.id == ctx.client.user.id)
|
||||
return result
|
||||
|
||||
while True:
|
||||
try:
|
||||
reaction, user = await ctx.client.wait_for('reaction_add', check=reaction_check, timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
|
||||
asyncio.create_task(discord_shield(out_msg.remove_reaction(reaction.emoji, user)))
|
||||
|
||||
# Change the page number
|
||||
if reaction.emoji == next_emoji:
|
||||
page_i += 1
|
||||
page_i %= page_count
|
||||
elif reaction.emoji == prev_emoji:
|
||||
page_i -= 1
|
||||
page_i %= page_count
|
||||
else:
|
||||
page_i = author_page
|
||||
|
||||
# Edit the message
|
||||
image = await submit_page_request(page_i)
|
||||
image_file = image_as_file(image, f"leaderboard_{page_i}.png")
|
||||
|
||||
await edit_files(
|
||||
ctx.client._connection.http,
|
||||
ctx.ch.id,
|
||||
out_msg.id,
|
||||
files=[image_file]
|
||||
)
|
||||
# Prefetch surrounding pages
|
||||
submit_page_request((page_i + 1) % page_count)
|
||||
submit_page_request((page_i - 1) % page_count)
|
||||
|
||||
# Clean up reactions
|
||||
try:
|
||||
await out_msg.clear_reactions()
|
||||
except discord.Forbidden:
|
||||
try:
|
||||
await out_msg.remove_reaction(next_emoji, ctx.client.user)
|
||||
await out_msg.remove_reaction(prev_emoji, ctx.client.user)
|
||||
except discord.NotFound:
|
||||
pass
|
||||
except discord.NotFound:
|
||||
pass
|
||||
|
||||
# Delete the image cache and explicit garbage collect
|
||||
del page_futures
|
||||
gc.collect()
|
||||
90
src/modules/pending-rewrite/gui-commands/module.py
Normal file
90
src/modules/pending-rewrite/gui-commands/module.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
import discord
|
||||
|
||||
from LionModule import LionModule
|
||||
|
||||
from meta import client
|
||||
from utils.ratelimits import RateLimit
|
||||
|
||||
from ..client import EmptyResponse, request
|
||||
|
||||
|
||||
class PluginModule(LionModule):
|
||||
def cmd(self, name, **kwargs):
|
||||
# Remove any existing command with this name
|
||||
for module in client.modules:
|
||||
for i, cmd in enumerate(module.cmds):
|
||||
if cmd.name == name:
|
||||
module.cmds.pop(i)
|
||||
|
||||
return super().cmd(name, **kwargs)
|
||||
|
||||
async def on_exception(self, ctx, exception):
|
||||
try:
|
||||
raise exception
|
||||
except (ConnectionError, EmptyResponse) as e:
|
||||
full_traceback = traceback.format_exc()
|
||||
only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only())
|
||||
|
||||
client.log(
|
||||
("Caught a communication exception while "
|
||||
"executing command '{cmdname}' from module '{module}' "
|
||||
"from user '{message.author}' (uid:{message.author.id}) "
|
||||
"in guild '{message.guild}' (gid:{guildid}) "
|
||||
"in channel '{message.channel}' (cid:{message.channel.id}).\n"
|
||||
"Message Content:\n"
|
||||
"{content}\n"
|
||||
"{traceback}\n\n"
|
||||
"{flat_ctx}").format(
|
||||
cmdname=ctx.cmd.name,
|
||||
module=ctx.cmd.module.name,
|
||||
message=ctx.msg,
|
||||
guildid=ctx.guild.id if ctx.guild else None,
|
||||
content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()),
|
||||
traceback=full_traceback,
|
||||
flat_ctx=ctx.flatten()
|
||||
),
|
||||
context="mid:{}".format(ctx.msg.id),
|
||||
level=logging.ERROR
|
||||
)
|
||||
error_embed = discord.Embed(title="Sorry, something went wrong!")
|
||||
error_embed.description = (
|
||||
"An unexpected error occurred while communicating with our rendering server!\n"
|
||||
"Our development team has been notified, and the issue should be fixed soon.\n"
|
||||
)
|
||||
if logging.getLogger().getEffectiveLevel() < logging.INFO:
|
||||
error_embed.add_field(
|
||||
name="Exception",
|
||||
value="`{}`".format(only_error)
|
||||
)
|
||||
|
||||
await ctx.reply(embed=error_embed)
|
||||
except Exception:
|
||||
await super().on_exception(ctx, exception)
|
||||
|
||||
|
||||
module = PluginModule("GUI")
|
||||
|
||||
ratelimit = RateLimit(5, 30)
|
||||
|
||||
logging.getLogger('PIL').setLevel(logging.WARNING)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def ping_server(client):
|
||||
start = time.time()
|
||||
try:
|
||||
await request('ping')
|
||||
except Exception:
|
||||
logging.error(
|
||||
"Failed to ping the rendering server!",
|
||||
exc_info=True
|
||||
)
|
||||
else:
|
||||
end = time.time()
|
||||
client.log(
|
||||
f"Rendering server responded in {end-start:.6f} seconds!",
|
||||
context="GUI INIT",
|
||||
)
|
||||
24
src/modules/pending-rewrite/gui-commands/reloadgui.py
Normal file
24
src/modules/pending-rewrite/gui-commands/reloadgui.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import importlib
|
||||
from .. import drawing
|
||||
from . import goals, leaderboard, stats, tasklist
|
||||
|
||||
from cmdClient import cmd, checks
|
||||
|
||||
|
||||
@cmd("reloadgui",
|
||||
desc="Reload all GUI drawing modules.")
|
||||
@checks.is_owner()
|
||||
async def cmd_reload_gui(ctx):
|
||||
importlib.reload(drawing.goals)
|
||||
importlib.reload(drawing.leaderboard)
|
||||
importlib.reload(drawing.profile)
|
||||
importlib.reload(drawing.stats)
|
||||
importlib.reload(drawing.tasklist)
|
||||
importlib.reload(drawing.weekly)
|
||||
importlib.reload(drawing.monthly)
|
||||
|
||||
importlib.reload(goals)
|
||||
importlib.reload(leaderboard)
|
||||
importlib.reload(stats)
|
||||
importlib.reload(tasklist)
|
||||
await ctx.reply("GUI plugin reloaded.")
|
||||
278
src/modules/pending-rewrite/gui-commands/stats.py
Normal file
278
src/modules/pending-rewrite/gui-commands/stats.py
Normal file
@@ -0,0 +1,278 @@
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from cmdClient.checks import in_guild
|
||||
|
||||
from utils.lib import utc_now
|
||||
from data import tables
|
||||
from data.conditions import LEQ
|
||||
from core import Lion
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
from modules.study.tracking.data import session_history
|
||||
from modules.stats.achievements import get_achievements_for
|
||||
|
||||
from ...cards import StatsCard, ProfileCard
|
||||
from ...utils import get_avatar_key, image_as_file
|
||||
|
||||
from ..module import module, ratelimit
|
||||
|
||||
|
||||
async def get_stats_card_for(ctx: Context, target):
|
||||
lion = Lion.fetch(ctx.guild.id, target.id)
|
||||
|
||||
history = session_history.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=target.id,
|
||||
select_columns=(
|
||||
"start_time",
|
||||
"(start_time + duration * interval '1 second') AS end_time"
|
||||
),
|
||||
_extra="ORDER BY start_time DESC"
|
||||
)
|
||||
|
||||
# Current economy balance (accounting for current session)
|
||||
workout_total = lion.data.workout_count
|
||||
|
||||
# Leaderboard ranks
|
||||
exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members)
|
||||
exclude.update(ctx.client.user_blacklist())
|
||||
exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id])
|
||||
if target.bot or target.id in exclude:
|
||||
time_rank = None
|
||||
coin_rank = None
|
||||
else:
|
||||
time_rank, coin_rank = tables.lions.queries.get_member_rank(ctx.guild.id, target.id, list(exclude or [0]))
|
||||
|
||||
# Study time
|
||||
# First get the all/month/week/day timestamps
|
||||
day_start = lion.day_start
|
||||
month_start = day_start.replace(day=1)
|
||||
period_timestamps = (
|
||||
datetime(1970, 1, 1),
|
||||
month_start,
|
||||
day_start - timedelta(days=day_start.weekday()),
|
||||
day_start
|
||||
)
|
||||
study_times = [0, 0, 0, 0]
|
||||
for i, timestamp in enumerate(period_timestamps):
|
||||
study_time = tables.session_history.queries.study_time_since(ctx.guild.id, target.id, timestamp)
|
||||
if not study_time:
|
||||
# So we don't make unecessary database calls
|
||||
break
|
||||
study_times[i] = study_time
|
||||
|
||||
# Streak data for the study run view
|
||||
streaks = []
|
||||
|
||||
streak = 0
|
||||
streak_end = None
|
||||
date = day_start
|
||||
daydiff = timedelta(days=1)
|
||||
|
||||
if 'sessions' in ctx.client.objects and lion.session:
|
||||
day_attended = True
|
||||
streak_end = day_start.day
|
||||
else:
|
||||
day_attended = None
|
||||
|
||||
periods = [(row['start_time'], row['end_time']) for row in history]
|
||||
|
||||
i = 0
|
||||
while i < len(periods):
|
||||
row = periods[i]
|
||||
i += 1
|
||||
if row[1] > date:
|
||||
# They attended this day
|
||||
day_attended = True
|
||||
if streak_end is None:
|
||||
streak_end = (date - month_start).days + 1
|
||||
continue
|
||||
elif day_attended is None:
|
||||
# Didn't attend today, but don't break streak
|
||||
day_attended = False
|
||||
date -= daydiff
|
||||
i -= 1
|
||||
continue
|
||||
elif not day_attended:
|
||||
# Didn't attend the day, streak broken
|
||||
date -= daydiff
|
||||
i -= 1
|
||||
pass
|
||||
else:
|
||||
# Attended the day
|
||||
streak += 1
|
||||
|
||||
# Move window to the previous day and try the row again
|
||||
day_attended = False
|
||||
prev_date = date
|
||||
date -= daydiff
|
||||
i -= 1
|
||||
|
||||
# Special case, when the last session started in the previous day
|
||||
# Then the day is already attended
|
||||
if i > 1 and date < periods[i-2][0] <= prev_date:
|
||||
day_attended = True
|
||||
if streak_end is None:
|
||||
streak_end = (date - month_start).days + 1
|
||||
|
||||
continue
|
||||
|
||||
if streak_end:
|
||||
streaks.append((streak_end - streak + 1, streak_end))
|
||||
streak_end = None
|
||||
streak = 0
|
||||
if date.month != day_start.month:
|
||||
break
|
||||
|
||||
# Handle loop exit state, i.e. the last streak
|
||||
if day_attended:
|
||||
streak += 1
|
||||
streaks.append((streak_end - streak + 1, streak_end))
|
||||
|
||||
# We have all the data for the stats card
|
||||
return await StatsCard.request(
|
||||
(time_rank, coin_rank),
|
||||
list(reversed(study_times)),
|
||||
workout_total,
|
||||
streaks,
|
||||
skin=StatsCard.skin_args_for(ctx)
|
||||
)
|
||||
|
||||
|
||||
async def get_profile_card_for(ctx: Context, target):
|
||||
lion = Lion.fetch(ctx.guild.id, target.id)
|
||||
|
||||
# Current economy balance (accounting for current session)
|
||||
coins = lion.coins
|
||||
season_time = lion.time
|
||||
|
||||
# Study League
|
||||
guild_badges = tables.study_badges.fetch_rows_where(guildid=ctx.guild.id)
|
||||
if lion.data.last_study_badgeid:
|
||||
current_badge = tables.study_badges.fetch(lion.data.last_study_badgeid)
|
||||
else:
|
||||
current_badge = None
|
||||
|
||||
next_badge = min(
|
||||
(badge for badge in guild_badges
|
||||
if badge.required_time > (current_badge.required_time if current_badge else 0)),
|
||||
key=lambda badge: badge.required_time,
|
||||
default=None
|
||||
)
|
||||
if current_badge:
|
||||
current_rank = (
|
||||
role.name if (role := ctx.guild.get_role(current_badge.roleid)) else str(current_badge.roleid),
|
||||
current_badge.required_time // 3600,
|
||||
next_badge.required_time // 3600 if next_badge else None
|
||||
)
|
||||
else:
|
||||
current_rank = None
|
||||
if next_badge:
|
||||
next_next_badge = min(
|
||||
(badge for badge in guild_badges if badge.required_time > next_badge.required_time),
|
||||
key=lambda badge: badge.required_time,
|
||||
default=None
|
||||
)
|
||||
next_rank = (
|
||||
role.name if (role := ctx.guild.get_role(next_badge.roleid)) else str(next_badge.roleid),
|
||||
next_badge.required_time // 3600,
|
||||
next_next_badge.required_time // 3600 if next_next_badge else None
|
||||
)
|
||||
else:
|
||||
next_rank = None
|
||||
|
||||
achievements = await get_achievements_for(target)
|
||||
|
||||
# We have all the data for the profile card
|
||||
avatar = get_avatar_key(ctx.client, target.id)
|
||||
return await ProfileCard.request(
|
||||
target.name,
|
||||
'#{}'.format(target.discriminator),
|
||||
coins,
|
||||
season_time,
|
||||
avatar=avatar,
|
||||
gems=ctx.client.data.gem_transactions.queries.get_gems_for(target.id),
|
||||
gifts=ctx.client.data.gem_transactions.queries.get_gifts_for(target.id),
|
||||
achievements=[i for i, ach in enumerate(achievements) if ach.level_id > 0],
|
||||
current_rank=current_rank,
|
||||
next_rank=next_rank,
|
||||
badges=lion.profile_tags,
|
||||
skin=ProfileCard.skin_args_for(ctx)
|
||||
)
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"stats",
|
||||
group="Statistics",
|
||||
desc="View your server study statistics!"
|
||||
)
|
||||
@in_guild()
|
||||
@ratelimit.ward(member=False)
|
||||
async def cmd_stats(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}stats
|
||||
{prefix}stats <mention>
|
||||
Description:
|
||||
View your study statistics in this server, or those of the mentioned member.
|
||||
"""
|
||||
# Identify the target
|
||||
if ctx.args:
|
||||
if not ctx.msg.mentions:
|
||||
return await ctx.error_reply("Please mention a user to view their statistics!")
|
||||
target = ctx.msg.mentions[0]
|
||||
else:
|
||||
target = ctx.author
|
||||
|
||||
# System sync
|
||||
Lion.sync()
|
||||
|
||||
# Fetch the cards
|
||||
futures = (
|
||||
asyncio.create_task(get_profile_card_for(ctx, target)),
|
||||
asyncio.create_task(get_stats_card_for(ctx, target))
|
||||
)
|
||||
await futures[0]
|
||||
await futures[1]
|
||||
|
||||
profile_image = futures[0].result()
|
||||
stats_image = futures[1].result()
|
||||
|
||||
profile_file = image_as_file(profile_image, f"profile_{target.id}.png")
|
||||
stats_file = image_as_file(stats_image, f"stats_{target.id}.png")
|
||||
|
||||
await ctx.reply(files=[profile_file, stats_file])
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"profile",
|
||||
group="Statistics",
|
||||
desc="View your personal study profile!"
|
||||
)
|
||||
@in_guild()
|
||||
@ratelimit.ward(member=False)
|
||||
async def cmd_profile(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}profile
|
||||
{prefix}profile <mention>
|
||||
Description:
|
||||
View your server study profile, or that of the mentioned user.
|
||||
"""
|
||||
# Identify the target
|
||||
if ctx.args:
|
||||
if not ctx.msg.mentions:
|
||||
return await ctx.error_reply("Please mention a user to view their profile!")
|
||||
target = ctx.msg.mentions[0]
|
||||
else:
|
||||
target = ctx.author
|
||||
|
||||
# System sync
|
||||
Lion.sync()
|
||||
|
||||
# Fetch the cards
|
||||
profile_image = await get_profile_card_for(ctx, target)
|
||||
profile_file = image_as_file(profile_image, f"profile_{target.id}.png")
|
||||
|
||||
await ctx.reply(file=profile_file)
|
||||
111
src/modules/pending-rewrite/gui-commands/tasklist.py
Normal file
111
src/modules/pending-rewrite/gui-commands/tasklist.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import asyncio
|
||||
import discord
|
||||
|
||||
from core import Lion
|
||||
from meta import client
|
||||
|
||||
from modules.todo.Tasklist import Tasklist as TextTasklist
|
||||
|
||||
from ...cards import TasklistCard
|
||||
from ...utils import get_avatar_key, image_as_file, edit_files
|
||||
|
||||
|
||||
widget_help = """
|
||||
Open your interactive tasklist with `{prefix}todo`, \
|
||||
then use the following commands to update your tasks. \
|
||||
The `<taskids>` may be given as comma separated numbers and ranges.
|
||||
|
||||
`<taskids>` Toggle the status (done/notdone) of the provided tasks.
|
||||
`add/+ <task>` Add a new TODO `task`. Each line is added as a separate task.
|
||||
`d/rm/- <taskids>` Remove the specified tasks.
|
||||
`c/check <taskids>` Check (mark as done) the specified tasks.
|
||||
`u/uncheck <taskids>` Uncheck (mark incomplete) the specified tasks.
|
||||
`cancel` Cancel the interactive tasklist mode.
|
||||
|
||||
*You do not need to write `{prefix}todo` before each command when the list is visible.*
|
||||
|
||||
**Examples**
|
||||
`add Read chapter 1` Add a new task `Read chapter 1`.
|
||||
`e 0 Notes chapter 1` Edit task `0` to say `Notes chapter 1`.
|
||||
`d 0, 5-7, 9` Delete tasks `0, 5, 6, 7, 9`.
|
||||
`0, 2-5, 9` Toggle the completion status of tasks `0, 2, 3, 4, 5, 9`.
|
||||
|
||||
[Click here to jump back]({jump_link})
|
||||
"""
|
||||
|
||||
|
||||
class GUITasklist(TextTasklist):
|
||||
async def _format_tasklist(self):
|
||||
tasks = [
|
||||
(i, task.content, bool(task.completed_at))
|
||||
for (i, task) in enumerate(self.tasklist)
|
||||
]
|
||||
avatar = get_avatar_key(client, self.member.id)
|
||||
lion = Lion.fetch(self.member.guild.id, self.member.id)
|
||||
date = lion.day_start
|
||||
self.pages = await TasklistCard.request(
|
||||
self.member.name,
|
||||
f"#{self.member.discriminator}",
|
||||
tasks,
|
||||
date,
|
||||
avatar=avatar,
|
||||
badges=lion.profile_tags,
|
||||
skin=TasklistCard.skin_args_for(guildid=self.member.guild.id, userid=self.member.id)
|
||||
)
|
||||
|
||||
return self.pages
|
||||
|
||||
async def _post(self):
|
||||
pages = self.pages
|
||||
|
||||
message = await self.channel.send(file=image_as_file(pages[self.current_page], "tasklist.png"))
|
||||
|
||||
# Add the reactions
|
||||
self.has_paging = len(pages) > 1
|
||||
for emoji in (self.paged_reaction_order if self.has_paging else self.non_paged_reaction_order):
|
||||
await message.add_reaction(emoji)
|
||||
|
||||
# Register
|
||||
if self.message:
|
||||
self.messages.pop(self.message.id, None)
|
||||
|
||||
self.message = message
|
||||
self.messages[message.id] = self
|
||||
|
||||
async def _update(self):
|
||||
if self.show_help:
|
||||
embed = discord.Embed(
|
||||
title="Tasklist widget guide",
|
||||
description=widget_help.format(
|
||||
prefix=client.prefix,
|
||||
jump_link=self.message.jump_url
|
||||
),
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
try:
|
||||
await self.member.send(embed=embed)
|
||||
except discord.Forbidden:
|
||||
await self.channel.send("Could not send you the guide! Please open your DMs first.")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
self.show_help = False
|
||||
await edit_files(
|
||||
self.message._state.http,
|
||||
self.channel.id,
|
||||
self.message.id,
|
||||
files=[image_as_file(self.pages[self.current_page], "tasklist.png")]
|
||||
)
|
||||
|
||||
|
||||
# Monkey patch the Tasklist fetch method to conditionally point to the GUI tasklist
|
||||
# TODO: Config setting for text/gui
|
||||
@classmethod
|
||||
def fetch_or_create(cls, ctx, flags, member, channel):
|
||||
factory = TextTasklist if flags['text'] else GUITasklist
|
||||
tasklist = GUITasklist.active.get((member.id, channel.id), None)
|
||||
if type(tasklist) != factory:
|
||||
tasklist = None
|
||||
return tasklist if tasklist is not None else factory(member, channel)
|
||||
|
||||
|
||||
TextTasklist.fetch_or_create = fetch_or_create
|
||||
169
src/modules/pending-rewrite/gui-commands/timer.py
Normal file
169
src/modules/pending-rewrite/gui-commands/timer.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
|
||||
import discord
|
||||
from utils.lib import utc_now
|
||||
from core import Lion
|
||||
from meta import client
|
||||
|
||||
from modules.study.timers.Timer import Timer
|
||||
|
||||
from ...cards import FocusTimerCard, BreakTimerCard
|
||||
|
||||
from ...utils import get_avatar_key, image_as_file, edit_files, asset_path
|
||||
|
||||
|
||||
async def status(self):
|
||||
stage = self.current_stage
|
||||
|
||||
name = self.data.pretty_name
|
||||
remaining = int((stage.end - utc_now()).total_seconds())
|
||||
duration = int(stage.duration)
|
||||
next_starts = int(stage.end.timestamp())
|
||||
users = [
|
||||
(get_avatar_key(client, member.id),
|
||||
session.duration if (session := Lion.fetch(member.guild.id, member.id).session) else 0,
|
||||
session.data.tag if session else None)
|
||||
for member in self.members
|
||||
]
|
||||
if stage.name == 'FOCUS':
|
||||
card_class = FocusTimerCard
|
||||
content = f"**Focus!** Session ends <t:{next_starts}:R>."
|
||||
else:
|
||||
card_class = BreakTimerCard
|
||||
content = f"**Have a rest!** Break finishes <t:{next_starts}:R>."
|
||||
|
||||
page = await card_class.request(
|
||||
name,
|
||||
remaining,
|
||||
duration,
|
||||
users=users,
|
||||
skin=card_class.skin_args_for(guildid=self.data.guildid)
|
||||
)
|
||||
|
||||
return {
|
||||
'content': content,
|
||||
'files': [image_as_file(page, name="timer.png")]
|
||||
}
|
||||
|
||||
|
||||
_guard_delay = 60
|
||||
_guarded = {} # timer channel id -> (last_executed_time, currently_waiting)
|
||||
|
||||
|
||||
async def guard_request(id):
|
||||
if (result := _guarded.get(id, None)):
|
||||
last, currently = result
|
||||
if currently:
|
||||
return False
|
||||
else:
|
||||
_guarded[id] = (last, True)
|
||||
await asyncio.sleep(_guard_delay - (time.time() - last))
|
||||
_guarded[id] = (time.time(), False)
|
||||
return True
|
||||
else:
|
||||
_guarded[id] = (time.time(), False)
|
||||
return True
|
||||
|
||||
|
||||
async def update_last_status(self):
|
||||
"""
|
||||
Update the last posted status message, if it exists.
|
||||
"""
|
||||
old_message = self.reaction_message
|
||||
|
||||
if not await guard_request(self.channelid):
|
||||
return
|
||||
if old_message != self.reaction_message:
|
||||
return
|
||||
|
||||
args = await self.status()
|
||||
repost = True
|
||||
if self.reaction_message:
|
||||
try:
|
||||
await edit_files(
|
||||
client._connection.http,
|
||||
self.reaction_message.channel.id,
|
||||
self.reaction_message.id,
|
||||
**args
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
else:
|
||||
repost = False
|
||||
|
||||
if repost and self.text_channel:
|
||||
try:
|
||||
self.reaction_message = await self.text_channel.send(**args)
|
||||
await self.reaction_message.add_reaction('✅')
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
|
||||
guild_locks = defaultdict(asyncio.Lock)
|
||||
|
||||
|
||||
async def play_alert(channel: discord.VoiceChannel, alert_file):
|
||||
if not channel.members:
|
||||
# Don't notify an empty channel
|
||||
return
|
||||
|
||||
async with guild_locks[channel.guild.id]:
|
||||
try:
|
||||
vc = channel.guild.voice_client
|
||||
if not vc:
|
||||
vc = await asyncio.wait_for(
|
||||
channel.connect(timeout=10, reconnect=False),
|
||||
20
|
||||
)
|
||||
elif vc.channel != channel:
|
||||
await vc.move_to(channel)
|
||||
except asyncio.TimeoutError:
|
||||
client.log(
|
||||
f"Timed out while attempting to connect to '{channel.name}' (cid:{channel.id}) "
|
||||
f"in '{channel.guild.name}' (gid:{channel.guild.id}).",
|
||||
context="TIMER_ALERT",
|
||||
level=logging.WARNING
|
||||
)
|
||||
vc = channel.guild.voice_client
|
||||
if vc:
|
||||
await vc.disconnect(force=True)
|
||||
return
|
||||
|
||||
audio_stream = open(alert_file, 'rb')
|
||||
try:
|
||||
vc.play(discord.PCMAudio(audio_stream), after=lambda e: audio_stream.close())
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
count = 0
|
||||
while vc.is_playing() and count < 10:
|
||||
await asyncio.sleep(1)
|
||||
count += 1
|
||||
|
||||
await vc.disconnect(force=True)
|
||||
|
||||
|
||||
async def notify_hook(self, old_stage, new_stage):
|
||||
try:
|
||||
if new_stage.name == 'BREAK':
|
||||
await play_alert(self.channel, asset_path('timer/voice/break_alert.wav'))
|
||||
else:
|
||||
await play_alert(self.channel, asset_path('timer/voice/focus_alert.wav'))
|
||||
except Exception:
|
||||
full_traceback = traceback.format_exc()
|
||||
client.log(
|
||||
f"Caught an unhandled exception while playing timer alert in '{self.channel.name}' (cid:{self.channel.id})"
|
||||
f" in '{self.channel.guild.name}' (gid:{self.channel.guild.id}).\n"
|
||||
f"{full_traceback}",
|
||||
context="TIMER_ALERT",
|
||||
level=logging.ERROR
|
||||
)
|
||||
|
||||
Timer.status = status
|
||||
Timer.update_last_status = update_last_status
|
||||
Timer.notify_hook = notify_hook
|
||||
43
src/modules/pending-rewrite/gui-commands/weekly_test.py
Normal file
43
src/modules/pending-rewrite/gui-commands/weekly_test.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import importlib
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from data.conditions import GEQ
|
||||
|
||||
from ..module import module
|
||||
|
||||
from .. import drawing
|
||||
from ..utils import get_avatar, image_as_file
|
||||
|
||||
|
||||
@module.cmd(
|
||||
'tasktest'
|
||||
)
|
||||
async def cmd_tasktest(ctx):
|
||||
importlib.reload(drawing.weekly)
|
||||
WeeklyStatsPage = drawing.weekly.WeeklyStatsPage
|
||||
|
||||
day_start = ctx.alion.day_start
|
||||
last_week_start = day_start - timedelta(days=7 + day_start.weekday())
|
||||
|
||||
history = ctx.client.data.session_history.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id,
|
||||
start_time=GEQ(last_week_start - timedelta(days=1)),
|
||||
select_columns=(
|
||||
"start_time",
|
||||
"(start_time + duration * interval '1 second') AS end_time"
|
||||
),
|
||||
_extra="ORDER BY start_time ASC"
|
||||
)
|
||||
timezone = ctx.alion.timezone
|
||||
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
|
||||
|
||||
page = WeeklyStatsPage(
|
||||
ctx.author.name,
|
||||
f"#{ctx.author.discriminator}",
|
||||
sessions,
|
||||
day_start
|
||||
)
|
||||
image = page.draw()
|
||||
|
||||
await ctx.reply(file=image_as_file(image, 'weekly_stats.png'))
|
||||
Reference in New Issue
Block a user