rewrite: Initial rewrite skeleton.

Remove modules that will no longer be required.
Move pending modules to pending-rewrite folders.
This commit is contained in:
2022-09-17 17:06:13 +10:00
parent a7f7dd6e7b
commit a5147323b5
162 changed files with 1 additions and 866 deletions

View File

@@ -0,0 +1,9 @@
# flake8: noqa
from .module import module
from . import data
from . import profile
from . import setprofile
from . import top_cmd
from . import goals
from . import achievements

View File

@@ -0,0 +1,485 @@
from typing import NamedTuple, Optional, Union
from datetime import timedelta
import pytz
import discord
from cmdClient.checks import in_guild
from LionContext import LionContext
from meta import client, conf
from core import Lion
from data.conditions import NOTNULL, LEQ
from utils.lib import utc_now
from modules.topgg.utils import topgg_upvote_link
from .module import module
class AchievementLevel(NamedTuple):
name: str
threshold: Union[int, float]
emoji: discord.PartialEmoji
class Achievement:
"""
ABC for a member or user achievement.
"""
# Name of the achievement
name: str = None
subtext: str = None
congrats_text: str = "Congratulations, you completed this challenge!"
# List of levels for the achievement. Must always contain a 0 level!
levels: list[AchievementLevel] = None
def __init__(self, guildid: int, userid: int):
self.guildid = guildid
self.userid = userid
# Current status of the achievement. None until calculated by `update`.
self.value: int = None
# Current level index in levels. None until calculated by `update`.
self.level_id: int = None
@staticmethod
def progress_bar(value, minimum, maximum, width=10) -> str:
"""
Build a text progress bar representing `value` between `minimum` and `maximum`.
"""
emojis = conf.emojis
proportion = (value - minimum) / (maximum - minimum)
sections = min(max(int(proportion * width), 0), width)
bar = []
# Starting segment
bar.append(str(emojis.progress_left_empty) if sections == 0 else str(emojis.progress_left_full))
# Full segments up to transition or end
if sections >= 2:
bar.append(str(emojis.progress_middle_full) * (sections - 2))
# Transition, if required
if 1 < sections < width:
bar.append(str(emojis.progress_middle_transition))
# Empty sections up to end
if sections < width:
bar.append(str(emojis.progress_middle_empty) * (width - max(sections, 1) - 1))
# End section
bar.append(str(emojis.progress_right_empty) if sections < width else str(emojis.progress_right_full))
# Join all the sections together and return
return ''.join(bar)
@property
def progress_text(self) -> str:
"""
A brief textual description of the current progress.
Intended to be overridden by achievement implementations.
"""
return f"{int(self.value)}/{self.next_level.threshold if self.next_level else self.level.threshold}"
def progress_field(self) -> tuple[str, str]:
"""
Builds the progress field for the achievement display.
"""
# TODO: Not adjusted for levels
# TODO: Add hint if progress is empty?
name = f"{self.levels[1].emoji} {self.name} ({self.progress_text})"
value = "**0** {progress_bar} **{threshold}**\n*{subtext}*".format(
subtext=(self.subtext if self.next_level else self.congrats_text) or '',
progress_bar=self.progress_bar(self.value, self.levels[0].threshold, self.levels[1].threshold),
threshold=self.levels[1].threshold
)
return (name, value)
@classmethod
async def fetch(cls, guildid: int, userid: int) -> 'Achievement':
"""
Fetch an Achievement status for the given member.
"""
return await cls(guildid, userid).update()
@property
def level(self) -> AchievementLevel:
"""
The current `AchievementLevel` for this member achievement.
"""
if self.level_id is None:
raise ValueError("Cannot obtain level before first update!")
return self.levels[self.level_id]
@property
def next_level(self) -> Optional[AchievementLevel]:
"""
The next `AchievementLevel` for this member achievement,
or `None` if it is at the maximum level.
"""
if self.level_id is None:
raise ValueError("Cannot obtain level before first update!")
if self.level_id == len(self.levels) - 1:
return None
else:
return self.levels[self.level_id + 1]
async def update(self) -> 'Achievement':
"""
Calculate and store the current member achievement status.
Returns `self` for easy chaining.
"""
# First fetch the value
self.value = await self._calculate_value()
# Then determine the current level
# Using 0 as a fallback in case the value is negative
self.level_id = next(
(i for i, level in reversed(list(enumerate(self.levels))) if level.threshold <= self.value),
0
)
# And return `self` for chaining
return self
async def _calculate_value(self) -> Union[int, float]:
"""
Calculate the current `value` of the member achievement.
Must be overridden by Achievement implementations.
"""
raise NotImplementedError
class Workout(Achievement):
sorting_index = 8
emoji_index = 4
name = "It's about Power"
subtext = "Workout 50 times"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 50, conf.emojis.active_achievement_4),
]
async def _calculate_value(self) -> int:
"""
Returns the total number of workouts from this user.
"""
return client.data.workout_sessions.select_one_where(
userid=self.userid,
select_columns="COUNT(*)"
)[0]
class StudyHours(Achievement):
sorting_index = 1
emoji_index = 1
name = "Dream Big"
subtext = "Study a total of 1000 hours"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_1),
]
async def _calculate_value(self) -> float:
"""
Returns the total number of hours this user has studied.
"""
past_session_total = client.data.session_history.select_one_where(
userid=self.userid,
select_columns="SUM(duration)"
)[0] or 0
current_session_total = client.data.current_sessions.select_one_where(
userid=self.userid,
select_columns="SUM(EXTRACT(EPOCH FROM (NOW() - start_time)))"
)[0] or 0
session_total = past_session_total + current_session_total
hours = session_total / 3600
return hours
class StudyStreak(Achievement):
sorting_index = 2
emoji_index = 2
name = "Consistency is Key"
subtext = "Reach a 100-day study streak"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 100, conf.emojis.active_achievement_2)
]
async def _calculate_value(self) -> int:
"""
Return the user's maximum global study streak.
"""
lion = Lion.fetch(self.guildid, self.userid)
history = client.data.session_history.select_where(
userid=self.userid,
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time DESC"
)
# Streak statistics
streak = 0
max_streak = 0
day_attended = True if 'sessions' in client.objects and lion.session else None
date = lion.day_start
daydiff = timedelta(days=1)
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
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)
streak = 0
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
max_streak = max(max_streak, streak)
return max_streak
class Voting(Achievement):
sorting_index = 7
emoji_index = 7
name = "We're a Team"
subtext = "[Vote]({}) 100 times on top.gg".format(topgg_upvote_link)
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 100, conf.emojis.active_achievement_7)
]
async def _calculate_value(self) -> int:
"""
Returns the number of times the user has voted for the bot.
"""
return client.data.topgg.select_one_where(
userid=self.userid,
select_columns="COUNT(*)"
)[0]
class DaysStudying(Achievement):
sorting_index = 3
emoji_index = 3
name = "Aim For The Moon"
subtext = "Study on 90 different days"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 90, conf.emojis.active_achievement_3)
]
async def _calculate_value(self) -> int:
"""
Returns the number of days the user has studied in total.
"""
lion = Lion.fetch(self.guildid, self.userid)
offset = int(lion.day_start.utcoffset().total_seconds())
with client.data.session_history.conn as conn:
cursor = conn.cursor()
# TODO: Consider DST offset.
cursor.execute(
"""
SELECT
COUNT(DISTINCT(date_trunc('day', (time AT TIME ZONE 'utc') + interval '{} seconds')))
FROM (
(SELECT start_time AS time FROM session_history WHERE userid=%s)
UNION
(SELECT (start_time + duration * interval '1 second') AS time FROM session_history WHERE userid=%s)
) AS times;
""".format(offset),
(self.userid, self.userid)
)
data = cursor.fetchone()
return data[0]
class TasksComplete(Achievement):
sorting_index = 4
emoji_index = 8
name = "One Step at a Time"
subtext = "Complete 1000 tasks"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_8)
]
async def _calculate_value(self) -> int:
"""
Returns the number of tasks the user has completed.
"""
return client.data.tasklist.select_one_where(
userid=self.userid,
completed_at=NOTNULL,
select_columns="COUNT(*)"
)[0]
class ScheduledSessions(Achievement):
sorting_index = 5
emoji_index = 5
name = "Be Accountable"
subtext = "Attend 500 scheduled sessions"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 500, conf.emojis.active_achievement_5)
]
async def _calculate_value(self) -> int:
"""
Returns the number of scheduled sesions the user has attended.
"""
return client.data.accountability_member_info.select_one_where(
userid=self.userid,
start_at=LEQ(utc_now()),
select_columns="COUNT(*)",
_extra="AND (duration > 0 OR last_joined_at IS NOT NULL)"
)[0]
class MonthlyHours(Achievement):
sorting_index = 6
emoji_index = 6
name = "The 30 Days Challenge"
subtext = "Study 100 hours in 30 days"
levels = [
AchievementLevel("Level 0", 0, None),
AchievementLevel("Level 1", 100, conf.emojis.active_achievement_6)
]
async def _calculate_value(self) -> float:
"""
Returns the maximum number of hours the user has studied in a month.
"""
# Get the first session so we know how far back to look
first_session = client.data.session_history.select_one_where(
userid=self.userid,
select_columns="MIN(start_time)"
)[0]
# Get the user's timezone
lion = Lion.fetch(self.guildid, self.userid)
# If the first session doesn't exist, simulate an existing session (to avoid an extra lookup)
first_session = first_session or lion.day_start - timedelta(days=1)
# Build the list of month start timestamps
month_start = lion.day_start.replace(day=1)
months = [month_start.astimezone(pytz.utc)]
while month_start >= first_session:
month_start -= timedelta(days=1)
month_start = month_start.replace(day=1)
months.append(month_start.astimezone(pytz.utc))
# Query the study times
data = client.data.session_history.queries.study_times_since(
self.guildid, self.userid, *months
)
cumulative_times = [row[0] or 0 for row in data]
times = [nxt - crt for nxt, crt in zip(cumulative_times[1:], cumulative_times[0:])]
max_time = max(cumulative_times[0], *times) if len(months) > 1 else cumulative_times[0]
return max_time / 3600
# Define the displayed achivement order
achievements = [
Workout,
StudyHours,
StudyStreak,
Voting,
DaysStudying,
TasksComplete,
ScheduledSessions,
MonthlyHours
]
async def get_achievements_for(member, panel_sort=False):
status = [
await ach.fetch(member.guild.id, member.id)
for ach in sorted(achievements, key=lambda cls: (cls.sorting_index if panel_sort else cls.emoji_index))
]
return status
@module.cmd(
name="achievements",
desc="View your progress towards the achievements!",
group="Statistics",
)
@in_guild()
async def cmd_achievements(ctx: LionContext):
"""
Usage``:
{prefix}achievements
Description:
View your progress towards attaining the achievement badges shown on your `profile`.
"""
status = await get_achievements_for(ctx.author, panel_sort=True)
embed = discord.Embed(
title="Achievements",
colour=discord.Colour.orange()
)
for achievement in status:
name, value = achievement.progress_field()
embed.add_field(
name=name, value=value, inline=False
)
await ctx.reply(embed=embed)

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

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

@@ -0,0 +1,266 @@
from datetime import datetime, timedelta
import discord
from cmdClient.checks import in_guild
from utils.lib import prop_tabulate, utc_now
from data import tables
from data.conditions import LEQ
from core import Lion
from modules.study.tracking.data import session_history
from .module import module
@module.cmd(
"stats",
group="Statistics",
desc="View your personal server study statistics!",
aliases=('profile',),
allow_before_ready=True
)
@in_guild()
async def cmd_stats(ctx):
"""
Usage``:
{prefix}stats
{prefix}stats <user mention>
Description:
View the study statistics for yourself or 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 statistics!")
target = ctx.msg.mentions[0]
else:
target = ctx.author
# System sync
Lion.sync()
# Fetch the required data
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)
coins = lion.coins
season_time = lion.time
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.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
period_timestamps = (
datetime(1970, 1, 1),
day_start.replace(day=1),
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 statistics
streak = 0
current_streak = None
max_streak = 0
day_attended = True if 'sessions' in ctx.client.objects and lion.session else None
date = day_start
daydiff = timedelta(days=1)
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
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
# Accountability stats
accountability = tables.accountability_member_info.select_where(
userid=target.id,
start_at=LEQ(utc_now()),
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
_extra="ORDER BY start_at DESC"
)
if len(accountability):
acc_duration = sum(row['duration'] for row in accountability)
acc_attended = sum(row['attended'] for row in accountability)
acc_total = len(accountability)
acc_rate = (acc_attended * 100) / acc_total
else:
acc_duration = 0
acc_rate = 0
# 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
)
# We have all the data
# Now start building the embed
embed = discord.Embed(
colour=discord.Colour.orange(),
title="Study Profile for {}".format(str(target))
)
embed.set_thumbnail(url=target.avatar_url)
# Add studying since if they have studied
if history:
embed.set_footer(text="Studying Since")
embed.timestamp = history[-1]['start_time']
# Set the description based on season time and server rank
if season_time:
time_str = "**{}:{:02}**".format(
season_time // 3600,
(season_time // 60) % 60
)
if time_rank is None:
rank_str = None
elif time_rank == 1:
rank_str = "1st"
elif time_rank == 2:
rank_str = "2nd"
elif time_rank == 3:
rank_str = "3rd"
else:
rank_str = "{}th".format(time_rank)
embed.description = "{} has studied for **{}**{}{}".format(
target.mention,
time_str,
" this season" if study_times[0] - season_time > 60 else "",
", and is ranked **{}** in the server!".format(rank_str) if rank_str else "."
)
else:
embed.description = "{} hasn't studied in this server yet!".format(target.mention)
# Build the stats table
stats = {}
stats['Coins Earned'] = "**{}** LC".format(
coins,
# "Rank `{}`".format(coin_rank) if coins and coin_rank else "Unranked"
)
if workout_total:
stats['Workouts'] = "**{}** sessions".format(workout_total)
if acc_duration:
stats['Accountability'] = "**{}** hours (`{:.0f}%` attended)".format(
acc_duration // 3600,
acc_rate
)
stats['Study Streak'] = "**{}** days{}".format(
current_streak,
" (longest **{}** days)".format(max_streak) if max_streak else ''
)
stats_table = prop_tabulate(*zip(*stats.items()))
# Build the time table
time_table = prop_tabulate(
('Daily', 'Weekly', 'Monthly', 'All Time'),
["{:02}:{:02}".format(t // 3600, (t // 60) % 60) for t in reversed(study_times)]
)
# Populate the embed
embed.add_field(name="Study Time", value=time_table)
embed.add_field(name="Statistics", value=stats_table)
# Add the study league field
if current_badge or next_badge:
current_str = (
"You are currently in <@&{}>!".format(current_badge.roleid) if current_badge else "No league yet!"
)
if next_badge:
needed = max(next_badge.required_time - season_time, 0)
next_str = "Study for **{:02}:{:02}** more to achieve <@&{}>.".format(
needed // 3600,
(needed // 60) % 60,
next_badge.roleid
)
else:
next_str = "You have reached the highest league! Congratulations!"
embed.add_field(
name="Study League",
value="{}\n{}".format(current_str, next_str),
inline=False
)
await ctx.reply(embed=embed)

View File

@@ -0,0 +1,227 @@
"""
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!"
)
if tagid == 0:
return await ctx.error_reply("Tags start at `1`!")
# 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

@@ -0,0 +1,119 @@
from cmdClient.checks import in_guild
import data
from core import Lion
from data import tables
from utils import interactive # noqa
from .module import module
first_emoji = "🥇"
second_emoji = "🥈"
third_emoji = "🥉"
@module.cmd(
"top",
desc="View the Study Time leaderboard.",
group="Statistics",
aliases=('ttop', 'toptime', 'top100'),
help_aliases={'top100': "View the Study Time top 100."}
)
@in_guild()
async def cmd_top(ctx):
"""
Usage``:
{prefix}top
{prefix}top100
Description:
Display the study time leaderboard, or the top 100.
Use the paging reactions or send `p<n>` to switch pages (e.g. `p11` to switch to page 11).
"""
# 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'),
'_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_index = None
entries = []
for i, (userid, time) in enumerate(user_data):
member = ctx.guild.get_member(userid)
name = member.display_name if member else str(userid)
name = name.replace('*', ' ').replace('_', ' ')
num_str = "{}.".format(i+1)
hours = time // 3600
minutes = time // 60 % 60
seconds = time % 60
time_str = "{}:{:02}:{:02}".format(
hours,
minutes,
seconds
)
if ctx.author.id == userid:
author_index = i
entries.append((num_str, name, time_str))
# Extract blocks
blocks = [entries[i:i+20] for i in range(0, len(entries), 20)]
block_count = len(blocks)
# Build strings
header = "Study Time Top 100" if top100 else "Study Time Leaderboard"
if block_count > 1:
header += " (Page {{page}}/{})".format(block_count)
# Build pages
pages = []
for i, block in enumerate(blocks):
max_num_l, max_name_l, max_time_l = [max(len(e[i]) for e in block) for i in (0, 1, 2)]
body = '\n'.join(
"{:>{}} {:<{}} \t {:>{}} {} {}".format(
entry[0], max_num_l,
entry[1], max_name_l + 2,
entry[2], max_time_l + 1,
first_emoji if i == 0 and j == 0 else (
second_emoji if i == 0 and j == 1 else (
third_emoji if i == 0 and j == 2 else ''
)
),
"" if author_index is not None and author_index == i * 20 + j else ""
)
for j, entry in enumerate(block)
)
title = header.format(page=i+1)
line = '='*len(title)
pages.append(
"```md\n{}\n{}\n{}```".format(title, line, body)
)
# Finally, page the results
await ctx.pager(pages, start_at=(author_index or 0)//20 if not top100 else 0)