rewrite: Profile, Stats, Leaderboard.
This commit is contained in:
@@ -9,11 +9,12 @@ from discord.ui.button import ButtonStyle
|
||||
from meta import LionBot, LionCog, LionContext
|
||||
from utils.lib import error_embed
|
||||
from utils.ui import LeoUI, AButton
|
||||
from wards import low_management
|
||||
|
||||
from . import babel
|
||||
from .data import StatsData
|
||||
from .ui import ProfileUI, WeeklyMonthlyUI
|
||||
from .settings import StatsSettings
|
||||
from .ui import ProfileUI, WeeklyMonthlyUI, LeaderboardUI
|
||||
from .settings import StatisticsSettings, StatisticsConfigUI
|
||||
|
||||
_p = babel._p
|
||||
|
||||
@@ -25,12 +26,17 @@ class StatsCog(LionCog):
|
||||
def __init__(self, bot: LionBot):
|
||||
self.bot = bot
|
||||
self.data = bot.db.load_registry(StatsData())
|
||||
self.settings = StatsSettings
|
||||
self.settings = StatisticsSettings()
|
||||
|
||||
async def cog_load(self):
|
||||
await self.data.init()
|
||||
|
||||
self.bot.core.user_config.register_model_setting(self.settings.UserGlobalStats)
|
||||
self.bot.core.guild_config.register_model_setting(self.settings.SeasonStart)
|
||||
self.bot.core.guild_config.register_setting(self.settings.UnrankedRoles)
|
||||
|
||||
configcog = self.bot.get_cog('ConfigCog')
|
||||
self.crossload_group(self.configure_group, configcog.configure_group)
|
||||
|
||||
@cmds.hybrid_command(
|
||||
name=_p('cmd:me', "me"),
|
||||
@@ -68,4 +74,67 @@ class StatsCog(LionCog):
|
||||
)
|
||||
@appcmds.guild_only
|
||||
async def leaderboard_cmd(self, ctx: LionContext):
|
||||
await ctx.interaction.response.defer(thinking=True)
|
||||
ui = LeaderboardUI(self.bot, ctx.author, ctx.guild)
|
||||
await ui.run(ctx.interaction)
|
||||
|
||||
# Setting commands
|
||||
@LionCog.placeholder_group
|
||||
@cmds.hybrid_group('configure', with_app_command=False)
|
||||
async def configure_group(self, ctx: LionContext):
|
||||
...
|
||||
|
||||
@configure_group.command(
|
||||
name=_p('cmd:configure_statistics', "statistics"),
|
||||
description=_p('cmd:configure_statistics|desc', "Statistics configuration panel")
|
||||
)
|
||||
@appcmds.rename(
|
||||
season_start=_p('cmd:configure_statistics|param:season_start', "season_start")
|
||||
)
|
||||
@appcmds.describe(
|
||||
season_start=_p(
|
||||
'cmd:configure_statistics|param:season_start|desc',
|
||||
"Time from which to start counting activity for rank badges and season leadeboards."
|
||||
)
|
||||
)
|
||||
@cmds.check(low_management)
|
||||
async def configure_statistics_cmd(self, ctx: LionContext,
|
||||
season_start: Optional[str] = None):
|
||||
t = self.bot.translator.t
|
||||
|
||||
# Type checking guards
|
||||
if not ctx.guild:
|
||||
return
|
||||
if not ctx.interaction:
|
||||
return
|
||||
|
||||
# Retrieve settings, using cache where possible
|
||||
setting_season_start = await self.settings.SeasonStart.get(ctx.guild.id)
|
||||
|
||||
modified = []
|
||||
if season_start is not None:
|
||||
data = await setting_season_start._parse_string(ctx.guild.id, season_start)
|
||||
setting_season_start.data = data
|
||||
await setting_season_start.write()
|
||||
modified.append(setting_season_start)
|
||||
|
||||
# Send update ack
|
||||
if modified:
|
||||
# TODO
|
||||
description = t(_p(
|
||||
'cmd:configure_statistics|resp:success|desc',
|
||||
"Activity ranks and season leaderboard will now be measured from {season_start}."
|
||||
)).format(
|
||||
season_start=setting_season_start.formatted
|
||||
)
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_green(),
|
||||
description=description
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
if ctx.channel.id not in StatisticsConfigUI._listening or not modified:
|
||||
# Launch setting group UI
|
||||
configui = StatisticsConfigUI(self.bot, ctx.guild.id, ctx.channel.id)
|
||||
await configui.run(ctx.interaction)
|
||||
await configui.wait()
|
||||
|
||||
0
src/modules/statistics/config.py
Normal file
0
src/modules/statistics/config.py
Normal file
@@ -1,14 +1,52 @@
|
||||
from typing import Optional, Iterable
|
||||
from enum import Enum
|
||||
from itertools import chain
|
||||
from psycopg import sql
|
||||
|
||||
from data import RowModel, Registry, Table
|
||||
from data.columns import Integer, String, Timestamp, Bool
|
||||
from data import RowModel, Registry, Table, RegisterEnum
|
||||
from data.columns import Integer, String, Timestamp, Bool, Column
|
||||
|
||||
from utils.lib import utc_now
|
||||
|
||||
|
||||
class StatisticType(Enum):
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TYPE StatisticType AS ENUM(
|
||||
'VOICE',
|
||||
'TEXT',
|
||||
'ANKI'
|
||||
)
|
||||
"""
|
||||
VOICE = ('VOICE',)
|
||||
TEXT = ('TEXT',)
|
||||
ANKI = ('ANKI',)
|
||||
|
||||
|
||||
class ExpType(Enum):
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TYPE ExperienceType AS ENUM(
|
||||
'VOICE_XP',
|
||||
'TEXT_XP',
|
||||
'QUEST_XP', -- Custom guild quests
|
||||
'ACHIEVEMENT_XP', -- Individual tracked achievements
|
||||
'BONUS_XP' -- Manually adjusted XP
|
||||
);
|
||||
"""
|
||||
VOICE_XP = 'VOICE_XP',
|
||||
TEXT_XP = 'TEXT_XP',
|
||||
QUEST_XP = 'QUEST_XP',
|
||||
ACHIEVEMENT_XP = 'ACHIEVEMENT_XP'
|
||||
BONUS_XP = 'BONUS_XP'
|
||||
|
||||
|
||||
class StatsData(Registry):
|
||||
StatisticType = RegisterEnum(StatisticType, name='StatisticType')
|
||||
ExpType = RegisterEnum(ExpType, name='ExperienceType')
|
||||
|
||||
class VoiceSessionStats(RowModel):
|
||||
"""
|
||||
View containing voice session statistics.
|
||||
@@ -21,7 +59,7 @@ class StatsData(Registry):
|
||||
guildid,
|
||||
start_time,
|
||||
duration,
|
||||
(start_time + duration * interval '1 second') AS end_time
|
||||
(timezone('UTC', start_time) + duration * interval '1 second') AS end_time
|
||||
FROM session_history
|
||||
UNION ALL
|
||||
SELECT
|
||||
@@ -115,6 +153,321 @@ class StatsData(Registry):
|
||||
)
|
||||
return [r['stime'] or 0 for r in await cursor.fetchall()]
|
||||
|
||||
@classmethod
|
||||
async def leaderboard_since(cls, guildid: int, since):
|
||||
"""
|
||||
Return the voice totals since the given time for each member in the guild.
|
||||
"""
|
||||
# Retrieve sum of all sessions (incl. ongoing) ending after given time
|
||||
first_query = sql.SQL(
|
||||
"""
|
||||
SELECT
|
||||
userid,
|
||||
sum(duration) as total_duration
|
||||
FROM voice_sessions_combined
|
||||
WHERE
|
||||
guildid = %s
|
||||
AND
|
||||
end_time > %s
|
||||
GROUP BY userid
|
||||
ORDER BY total_duration DESC
|
||||
"""
|
||||
)
|
||||
first_query_args = (guildid, since)
|
||||
|
||||
# Retrieve how much we "overshoot", from sessions which intersect the given time
|
||||
second_query = sql.SQL(
|
||||
"""
|
||||
SELECT
|
||||
SUM(EXTRACT(EPOCH FROM (%s - start_time))) AS diff,
|
||||
userid
|
||||
FROM voice_sessions_combined
|
||||
WHERE
|
||||
guildid = %s
|
||||
AND
|
||||
start_time < %s
|
||||
AND
|
||||
end_time > %s
|
||||
GROUP BY userid
|
||||
"""
|
||||
)
|
||||
second_query_args = (since, guildid, since, since)
|
||||
|
||||
conn = await cls._connector.get_connection()
|
||||
async with conn.transaction():
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(second_query, second_query_args)
|
||||
overshoot_rows = await cursor.fetchall()
|
||||
overshoot = {row['userid']: int(row['diff']) for row in overshoot_rows}
|
||||
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(first_query, first_query_args)
|
||||
leaderboard = [
|
||||
(row['userid'], int(row['total_duration'] - overshoot.get(row['userid'], 0)))
|
||||
for row in await cursor.fetchall()
|
||||
]
|
||||
leaderboard.sort(key=lambda t: t[1], reverse=True)
|
||||
return leaderboard
|
||||
|
||||
@classmethod
|
||||
async def leaderboard_all(cls, guildid: int):
|
||||
"""
|
||||
Return the all-time voice totals for the given guild.
|
||||
"""
|
||||
query = sql.SQL(
|
||||
"""
|
||||
SELECT userid, sum(duration) as total_duration
|
||||
FROM voice_sessions_combined
|
||||
WHERE guildid = %s
|
||||
GROUP BY userid
|
||||
ORDER BY total_duration DESC
|
||||
"""
|
||||
)
|
||||
|
||||
conn = await cls._connector.get_connection()
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(query, (guildid, ))
|
||||
leaderboard = [
|
||||
(row['userid'], int(row['total_duration']))
|
||||
for row in await cursor.fetchall()
|
||||
]
|
||||
return leaderboard
|
||||
|
||||
class MemberExp(RowModel):
|
||||
"""
|
||||
Model representing a member experience update.
|
||||
|
||||
Schema
|
||||
------
|
||||
CREATE TABLE member_experience(
|
||||
member_expid BIGSERIAL PRIMARY KEY,
|
||||
guildid BIGINT NOT NULL,
|
||||
userid BIGINT NOT NULL,
|
||||
earned_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'UTC'),
|
||||
amount INTEGER NOT NULL,
|
||||
exp_type ExperienceType NOT NULL,
|
||||
transactionid INTEGER REFERENCES coin_transactions ON DELETE SET NULL,
|
||||
FOREIGN KEY (guildid, userid) REFERENCES members ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX member_experience_members ON member_experience (guildid, userid, earned_at);
|
||||
CREATE INDEX member_experience_guilds ON member_experience (guildid, earned_at);
|
||||
"""
|
||||
_tablename_ = 'member_experience'
|
||||
|
||||
member_expid = Integer(primary=True)
|
||||
guildid = Integer()
|
||||
userid = Integer()
|
||||
earned_at = Timestamp()
|
||||
amount = Integer()
|
||||
exp_type: Column[ExpType] = Column()
|
||||
transactionid = Integer()
|
||||
|
||||
@classmethod
|
||||
async def xp_since(cls, guildid: int, userid: int, *starts):
|
||||
query = sql.SQL(
|
||||
"""
|
||||
SELECT
|
||||
(
|
||||
SELECT
|
||||
SUM(amount)
|
||||
FROM member_experience s
|
||||
WHERE
|
||||
s.guildid = %s
|
||||
AND s.userid = %s
|
||||
AND s.earned_at >= t._start
|
||||
) AS exp
|
||||
FROM
|
||||
(VALUES ({}))
|
||||
AS
|
||||
t (_start)
|
||||
ORDER BY t._start
|
||||
"""
|
||||
).format(
|
||||
sql.SQL('), (').join(
|
||||
sql.Placeholder() for _ in starts
|
||||
)
|
||||
)
|
||||
conn = await cls._connector.get_connection()
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
query,
|
||||
tuple(chain((guildid, userid), starts))
|
||||
)
|
||||
return [r['exp'] or 0 for r in await cursor.fetchall()]
|
||||
|
||||
@classmethod
|
||||
async def xp_between(cls, guildid: int, userid: int, *points):
|
||||
blocks = zip(points, points[1:])
|
||||
query = sql.SQL(
|
||||
"""
|
||||
SELECT
|
||||
(
|
||||
SELECT
|
||||
SUM(amount)
|
||||
FROM member_experience s
|
||||
WHERE
|
||||
s.guildid = %s
|
||||
AND s.userid = %s
|
||||
AND s.earned_at >= periods._start
|
||||
AND s.earned_at < periods._end
|
||||
) AS period_xp
|
||||
FROM
|
||||
(VALUES {})
|
||||
AS
|
||||
periods (_start, _end)
|
||||
ORDER BY periods._start
|
||||
"""
|
||||
).format(
|
||||
sql.SQL(', ').join(
|
||||
sql.SQL("({}, {})").format(sql.Placeholder(), sql.Placeholder()) for _ in points[1:]
|
||||
)
|
||||
)
|
||||
conn = await cls._connector.get_connection()
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
query,
|
||||
tuple(chain((guildid, userid), *blocks))
|
||||
)
|
||||
return [r['period_xp'] or 0 for r in await cursor.fetchall()]
|
||||
|
||||
@classmethod
|
||||
async def leaderboard_since(cls, guildid: int, since):
|
||||
"""
|
||||
Return the XP totals for the given guild since the given time.
|
||||
"""
|
||||
query = sql.SQL(
|
||||
"""
|
||||
SELECT userid, sum(amount) AS total_xp
|
||||
FROM member_experience
|
||||
WHERE guildid = %s AND earned_at >= %s
|
||||
GROUP BY userid
|
||||
ORDER BY total_xp DESC
|
||||
"""
|
||||
)
|
||||
|
||||
conn = await cls._connector.get_connection()
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(query, (guildid, since))
|
||||
leaderboard = [
|
||||
(row['userid'], int(row['total_xp']))
|
||||
for row in await cursor.fetchall()
|
||||
]
|
||||
return leaderboard
|
||||
|
||||
@classmethod
|
||||
async def leaderboard_all(cls, guildid: int):
|
||||
"""
|
||||
Return the all-time XP totals for the given guild.
|
||||
"""
|
||||
query = sql.SQL(
|
||||
"""
|
||||
SELECT userid, sum(amount) AS total_xp
|
||||
FROM member_experience
|
||||
WHERE guildid = %s
|
||||
GROUP BY userid
|
||||
ORDER BY total_xp DESC
|
||||
"""
|
||||
)
|
||||
|
||||
conn = await cls._connector.get_connection()
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(query, (guildid, ))
|
||||
leaderboard = [
|
||||
(row['userid'], int(row['total_xp']))
|
||||
for row in await cursor.fetchall()
|
||||
]
|
||||
return leaderboard
|
||||
|
||||
class UserExp(RowModel):
|
||||
"""
|
||||
Model representing a user experience update.
|
||||
|
||||
Schema
|
||||
------
|
||||
CREATE TABLE user_experience(
|
||||
user_expid BIGSERIAL PRIMARY KEY,
|
||||
userid BIGINT NOT NULL,
|
||||
earned_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'UTC'),
|
||||
amount INTEGER NOT NULL,
|
||||
exp_type ExperienceType NOT NULL,
|
||||
FOREIGN KEY (userid) REFERENCES user_config ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX user_experience_users ON user_experience (userid, earned_at);
|
||||
"""
|
||||
_tablename_ = 'user_experience'
|
||||
|
||||
user_expid = Integer(primary=True)
|
||||
userid = Integer()
|
||||
earned_at = Timestamp()
|
||||
amount = Integer()
|
||||
exp_type: Column[ExpType] = Column()
|
||||
|
||||
@classmethod
|
||||
async def xp_since(cls, userid: int, *starts):
|
||||
query = sql.SQL(
|
||||
"""
|
||||
SELECT
|
||||
(
|
||||
SELECT
|
||||
SUM(amount)
|
||||
FROM user_experience s
|
||||
WHERE
|
||||
s.userid = %s
|
||||
AND s.start_time >= t._start
|
||||
) AS exp
|
||||
FROM
|
||||
(VALUES ({}))
|
||||
AS
|
||||
t (_start)
|
||||
ORDER BY t._start
|
||||
"""
|
||||
).format(
|
||||
sql.SQL('), (').join(
|
||||
sql.Placeholder() for _ in starts
|
||||
)
|
||||
)
|
||||
conn = await cls._connector.get_connection()
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
query,
|
||||
tuple(chain((userid,), starts))
|
||||
)
|
||||
return [r['exp'] or 0 for r in await cursor.fetchall()]
|
||||
|
||||
@classmethod
|
||||
async def xp_between(cls, userid: int, *points):
|
||||
blocks = zip(points, points[1:])
|
||||
query = sql.SQL(
|
||||
"""
|
||||
SELECT
|
||||
(
|
||||
SELECT
|
||||
SUM(global_xp)
|
||||
FROM user_experience s
|
||||
WHERE
|
||||
s.userid = %s
|
||||
AND s.start_time >= periods._start
|
||||
AND s.start_time < periods._end
|
||||
) AS period_xp
|
||||
FROM
|
||||
(VALUES {})
|
||||
AS
|
||||
periods (_start, _end)
|
||||
ORDER BY periods._start
|
||||
"""
|
||||
).format(
|
||||
sql.SQL(', ').join(
|
||||
sql.SQL("({}, {})").format(sql.Placeholder(), sql.Placeholder()) for _ in points[1:]
|
||||
)
|
||||
)
|
||||
conn = await cls._connector.get_connection()
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
query,
|
||||
tuple(chain((userid,), *blocks))
|
||||
)
|
||||
return [r['period_xp'] or 0 for r in await cursor.fetchall()]
|
||||
|
||||
class ProfileTag(RowModel):
|
||||
"""
|
||||
Schema
|
||||
@@ -256,3 +609,6 @@ class StatsData(Registry):
|
||||
content = String()
|
||||
completed = Bool()
|
||||
_timestamp = Timestamp()
|
||||
|
||||
unranked_roles = Table('unranked_roles')
|
||||
visible_statistics = Table('visible_statistics')
|
||||
|
||||
@@ -5,6 +5,7 @@ from data import NULL
|
||||
from meta import LionBot
|
||||
from gui.cards import WeeklyGoalCard, MonthlyGoalCard
|
||||
from gui.base import CardMode
|
||||
from tracking.text.data import TextTrackerData
|
||||
|
||||
from ..data import StatsData
|
||||
from ..lib import extract_weekid, extract_monthid, apply_week_offset, apply_month_offset
|
||||
@@ -41,7 +42,15 @@ async def get_goals_card(
|
||||
key = {'guildid': guildid or 0, 'userid': userid, 'monthid': periodid}
|
||||
|
||||
# Extract goals and tasks
|
||||
goals = await goal_model.fetch(*key.values())
|
||||
# TODO: Major data model fixy fixy here
|
||||
if guildid:
|
||||
goals = await goal_model.fetch_or_create(*key.values())
|
||||
else:
|
||||
goals = await goal_model.fetch(*key.values())
|
||||
if not goals:
|
||||
from collections import defaultdict
|
||||
goals = defaultdict(lambda: -1)
|
||||
|
||||
task_rows = await tasks_model.fetch_where(**key)
|
||||
tasks = [(i, row.content, bool(row.completed)) for i, row in enumerate(task_rows)]
|
||||
|
||||
@@ -58,11 +67,17 @@ async def get_goals_card(
|
||||
tasks_completed = results[0]['total'] if results else 0
|
||||
|
||||
# Set and compute correct middle goal column
|
||||
# if mode in (CardMode.VOICE, CardMode.STUDY):
|
||||
if True:
|
||||
if mode in (CardMode.VOICE, CardMode.STUDY):
|
||||
model = data.VoiceSessionStats
|
||||
middle_completed = (await model.study_times_between(guildid or None, userid, start, end))[0]
|
||||
middle_goal = goals['study_goal']
|
||||
elif mode is CardMode.TEXT:
|
||||
model = TextTrackerData.TextSessions
|
||||
middle_goal = goals['message_goal']
|
||||
if guildid:
|
||||
middle_completed = (await model.member_messages_between(guildid, userid, start, end))[0]
|
||||
else:
|
||||
middle_completed = (await model.user_messages_between(userid, start, end))[0]
|
||||
|
||||
# Compute schedule session progress
|
||||
# TODO
|
||||
|
||||
74
src/modules/statistics/graphics/leaderboard.py
Normal file
74
src/modules/statistics/graphics/leaderboard.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from meta import LionBot
|
||||
|
||||
from gui.cards import LeaderboardCard
|
||||
from gui.base import CardMode
|
||||
|
||||
|
||||
async def get_leaderboard_card(
|
||||
bot: LionBot, highlightid: int, guildid: int,
|
||||
mode: CardMode,
|
||||
entry_data: list[tuple[int, int, int]], # userid, position, time
|
||||
):
|
||||
"""
|
||||
Render a leaderboard card with given parameters.
|
||||
"""
|
||||
guild = bot.get_guild(guildid)
|
||||
if guild is None:
|
||||
raise ValueError("Attempting to build leaderboard for non-existent guild!")
|
||||
|
||||
# Need to do two passes here in case we need to do a db request for the avatars or names
|
||||
avatars = {}
|
||||
names = {}
|
||||
missing = []
|
||||
for userid, _, _ in entry_data:
|
||||
hash = name = None
|
||||
if guild and (member := guild.get_member(userid)):
|
||||
hash = member.avatar.key
|
||||
name = member.display_name
|
||||
elif (user := bot.get_user(userid)):
|
||||
hash = user.avatar.key
|
||||
name = user.name
|
||||
elif (user_data := bot.core.data.User._cache_.get((userid,))):
|
||||
hash = user_data.avatar_hash
|
||||
name = user_data.name
|
||||
|
||||
if hash:
|
||||
avatars[userid] = hash
|
||||
names[userid] = name or 'Unknown'
|
||||
else:
|
||||
missing.append(userid)
|
||||
|
||||
if missing:
|
||||
# We were unable to retrieve information for some userids
|
||||
# Bulk-fetch missing users from data
|
||||
data = await bot.core.data.User.fetch_where(userid=missing)
|
||||
for user_data in data:
|
||||
avatars[user_data.userid] = user_data.avatar_hash
|
||||
names[user_data.userid] = user_data.name or 'Unknown'
|
||||
missing.remove(user_data.userid)
|
||||
|
||||
if missing:
|
||||
# Some of the users were missing from data
|
||||
# This should be impossible (by FKEY constraints on sessions)
|
||||
# But just in case...
|
||||
for userid in missing:
|
||||
avatars[userid] = None
|
||||
names[userid] = str(userid)
|
||||
|
||||
highlight = None
|
||||
entries = []
|
||||
for userid, position, duration in entry_data:
|
||||
entries.append(
|
||||
(userid, position, duration, names[userid], (userid, avatars[userid]))
|
||||
)
|
||||
if userid == highlightid:
|
||||
highlight = position
|
||||
|
||||
# Request Card
|
||||
card = LeaderboardCard(
|
||||
skin={'mode': mode},
|
||||
server_name=guild.name,
|
||||
entries=entries,
|
||||
highlight=highlight
|
||||
)
|
||||
return card
|
||||
@@ -6,6 +6,7 @@ from data import ORDER
|
||||
from meta import LionBot
|
||||
from gui.cards import MonthlyStatsCard
|
||||
from gui.base import CardMode
|
||||
from tracking.text.data import TextTrackerData
|
||||
|
||||
from ..data import StatsData
|
||||
from ..lib import apply_month_offset
|
||||
@@ -34,8 +35,23 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
|
||||
[0]*(calendar.monthrange(month.year, month.month)[1]) for month in months
|
||||
]
|
||||
|
||||
# TODO: Select model based on card mode
|
||||
model = data.VoiceSessionStats
|
||||
if mode is CardMode.VOICE:
|
||||
model = data.VoiceSessionStats
|
||||
req = model.study_times_between
|
||||
reqkey = (guildid or None, userid,)
|
||||
elif mode is CardMode.TEXT:
|
||||
model = TextTrackerData.TextSessions
|
||||
if guildid:
|
||||
req = model.member_messages_between
|
||||
reqkey = (guildid, userid,)
|
||||
else:
|
||||
req = model.user_messages_between
|
||||
reqkey = (userid,)
|
||||
else:
|
||||
# TODO: ANKI
|
||||
model = data.VoiceSessionStats
|
||||
req = model.study_times_between
|
||||
reqkey = (guildid or None, userid,)
|
||||
|
||||
# Get first session
|
||||
query = model.table.select_where().order_by('start_time', ORDER.ASC).limit(1)
|
||||
@@ -50,10 +66,10 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
|
||||
longest_streak = 0
|
||||
else:
|
||||
first_day = first_session.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
first_month = first_day.replace(day=1)
|
||||
# first_month = first_day.replace(day=1)
|
||||
|
||||
# Build list of day starts up to now, or end of requested month
|
||||
requests = []
|
||||
requests = [first_day]
|
||||
end_of_req = target_end if offset else today
|
||||
day = first_day
|
||||
while day <= end_of_req:
|
||||
@@ -61,7 +77,7 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
|
||||
requests.append(day)
|
||||
|
||||
# Request times between requested days
|
||||
day_stats = await model.study_times_between(guildid or None, userid, *requests)
|
||||
day_stats = await req(*reqkey, *requests)
|
||||
|
||||
# Compute current streak and longest streak
|
||||
current_streak = 0
|
||||
@@ -79,7 +95,10 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
|
||||
if day < months[0]:
|
||||
break
|
||||
i = offsets[(day.year, day.month)]
|
||||
monthly[i][day.day - 1] = stat / 3600
|
||||
if mode in (CardMode.VOICE, CardMode.STUDY):
|
||||
monthly[i][day.day - 1] = stat / 3600
|
||||
else:
|
||||
monthly[i][day.day - 1] = stat
|
||||
|
||||
# Get member profile
|
||||
if user:
|
||||
|
||||
91
src/modules/statistics/graphics/profile.py
Normal file
91
src/modules/statistics/graphics/profile.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import discord
|
||||
|
||||
from meta import LionBot
|
||||
from gui.cards import ProfileCard
|
||||
|
||||
from modules.ranks.cog import RankCog
|
||||
from modules.ranks.utils import format_stat_range
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..cog import StatsCog
|
||||
|
||||
|
||||
async def get_profile_card(bot: LionBot, userid: int, guildid: int):
|
||||
ranks: Optional[RankCog] = bot.get_cog('RankCog')
|
||||
stats: Optional[StatsCog] = bot.get_cog('StatsCog')
|
||||
if ranks is None or stats is None:
|
||||
return
|
||||
|
||||
guild = bot.get_guild(guildid)
|
||||
if guild is None:
|
||||
return
|
||||
|
||||
lion = await bot.core.lions.fetch_member(guildid, userid)
|
||||
luser = lion.luser
|
||||
member = await lion.fetch_member()
|
||||
|
||||
if member:
|
||||
username = (member.display_name, '#' + str(member.discriminator))
|
||||
avatar = member.avatar.key
|
||||
else:
|
||||
username = (lion.data.display_name, "#????")
|
||||
avatar = luser.data.avatar_hash
|
||||
|
||||
profile_badges = await stats.data.ProfileTag.fetch_tags(guildid, userid)
|
||||
|
||||
# Fetch current and next guild rank
|
||||
season_rank = await ranks.get_member_rank(guildid, userid)
|
||||
rank_type = lion.lguild.config.get('rank_type').value
|
||||
crank = season_rank.current_rank
|
||||
nrank = season_rank.next_rank
|
||||
if crank:
|
||||
roleid = crank.roleid
|
||||
role = guild.get_role(roleid)
|
||||
name = role.name if role else str(role.id)
|
||||
minimum = crank.required
|
||||
maximum = nrank.required if nrank else None
|
||||
rangestr = format_stat_range(rank_type, minimum, maximum)
|
||||
if maximum is None:
|
||||
rangestr = f"≥ {rangestr}"
|
||||
current_rank = (name, rangestr)
|
||||
|
||||
if maximum:
|
||||
rank_progress = (season_rank.stat - minimum) / (maximum - minimum)
|
||||
else:
|
||||
rank_progress = 1
|
||||
else:
|
||||
current_rank = None
|
||||
rank_progress = 0
|
||||
|
||||
if nrank:
|
||||
roleid = nrank.roleid
|
||||
role = guild.get_role(roleid)
|
||||
name = role.name if role else str(role.id)
|
||||
minimum = nrank.required
|
||||
|
||||
guild_ranks = await ranks.get_guild_ranks(guildid)
|
||||
nnrank = next((rank for rank in guild_ranks if rank.required > nrank.required), None)
|
||||
maximum = nnrank.required if nnrank else None
|
||||
rangestr = format_stat_range(rank_type, minimum, maximum)
|
||||
if maximum is None:
|
||||
rangestr = f"≥ {rangestr}"
|
||||
next_rank = (name, rangestr)
|
||||
else:
|
||||
next_rank = None
|
||||
|
||||
achievements = (0, 1)
|
||||
|
||||
card = ProfileCard(
|
||||
user=username,
|
||||
avatar=(userid, avatar),
|
||||
coins=lion.data.coins, gems=luser.data.gems, gifts=0,
|
||||
profile_badges=profile_badges,
|
||||
achievements=achievements,
|
||||
current_rank=current_rank,
|
||||
rank_progress=rank_progress,
|
||||
next_rank=next_rank
|
||||
)
|
||||
return card
|
||||
@@ -5,15 +5,16 @@ import discord
|
||||
|
||||
from meta import LionBot
|
||||
from gui.cards import StatsCard
|
||||
from gui.base import CardMode
|
||||
|
||||
from ..data import StatsData
|
||||
|
||||
|
||||
async def get_stats_card(bot: LionBot, userid: int, guildid: int):
|
||||
async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode):
|
||||
data: StatsData = bot.get_cog('StatsCog').data
|
||||
|
||||
# TODO: Workouts
|
||||
# TODO: Leaderboard rankings
|
||||
# TODO: Leaderboard rankings for this season or all time
|
||||
guildid = guildid or 0
|
||||
|
||||
lion = await bot.core.lions.fetch_member(guildid, userid)
|
||||
@@ -31,20 +32,40 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int):
|
||||
)
|
||||
|
||||
# Extract the study times for each period
|
||||
study_times = await data.VoiceSessionStats.study_times_since(guildid, userid, *period_timestamps)
|
||||
print("Study times", study_times)
|
||||
if mode in (CardMode.STUDY, CardMode.VOICE):
|
||||
model = data.VoiceSessionStats
|
||||
refkey = (guildid, userid)
|
||||
ref_since = model.study_times_since
|
||||
ref_between = model.study_times_between
|
||||
elif mode is CardMode.TEXT:
|
||||
if guildid:
|
||||
model = data.MemberExp
|
||||
refkey = (guildid, userid)
|
||||
else:
|
||||
model = data.UserExp
|
||||
refkey = (userid,)
|
||||
ref_since = model.xp_since
|
||||
ref_between = model.xp_between
|
||||
else:
|
||||
# TODO ANKI
|
||||
model = data.VoiceSessionStats
|
||||
refkey = (guildid, userid)
|
||||
ref_since = model.study_times_since
|
||||
ref_between = model.study_times_between
|
||||
|
||||
study_times = await ref_since(*refkey, *period_timestamps)
|
||||
print("Period study times: ", study_times)
|
||||
|
||||
# Calculate streak data by requesting times per day
|
||||
# First calculate starting timestamps for each day
|
||||
days = list(range(0, today.day + 2))
|
||||
day_timestamps = [month_start + timedelta(days=day - 1) for day in days]
|
||||
study_times = await data.VoiceSessionStats.study_times_between(guildid, userid, *day_timestamps)
|
||||
print("Study times", study_times)
|
||||
study_times_month = await ref_between(*refkey, *day_timestamps)
|
||||
|
||||
# Then extract streak tuples
|
||||
streaks = []
|
||||
streak_start = None
|
||||
for day, stime in zip(days, study_times):
|
||||
for day, stime in zip(days, study_times_month):
|
||||
stime = stime or 0
|
||||
if stime > 0 and streak_start is None:
|
||||
streak_start = day
|
||||
@@ -59,5 +80,6 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int):
|
||||
list(reversed(study_times)),
|
||||
100,
|
||||
streaks,
|
||||
skin={'mode': mode}
|
||||
)
|
||||
return card
|
||||
|
||||
@@ -5,6 +5,7 @@ from data import ORDER
|
||||
from meta import LionBot
|
||||
from gui.cards import WeeklyStatsCard
|
||||
from gui.base import CardMode
|
||||
from tracking.text.data import TextTrackerData
|
||||
|
||||
from ..data import StatsData
|
||||
|
||||
@@ -20,13 +21,27 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int,
|
||||
user = await bot.fetch_user(userid)
|
||||
today = lion.today
|
||||
week_start = today - timedelta(days=today.weekday()) - timedelta(weeks=offset)
|
||||
days = [week_start + timedelta(i) for i in range(-7, 7 if offset else (today.weekday() + 1))]
|
||||
days = [week_start + timedelta(i) for i in range(-7, 8 if offset else (today.weekday() + 2))]
|
||||
|
||||
# TODO: Select statistics model based on mode
|
||||
model = data.VoiceSessionStats
|
||||
if mode is CardMode.VOICE:
|
||||
model = data.VoiceSessionStats
|
||||
day_stats = await model.study_times_between(guildid or None, userid, *days)
|
||||
day_stats = list(map(lambda n: n // 3600, day_stats))
|
||||
elif mode is CardMode.TEXT:
|
||||
model = TextTrackerData.TextSessions
|
||||
if guildid:
|
||||
day_stats = await model.member_messages_between(guildid, userid, *days)
|
||||
else:
|
||||
day_stats = await model.user_messages_between(userid, *days)
|
||||
else:
|
||||
# TODO: ANKI
|
||||
model = data.VoiceSessionStats
|
||||
day_stats = await model.study_times_between(guildid or None, userid, *days)
|
||||
day_stats = list(map(lambda n: n // 3600, day_stats))
|
||||
|
||||
# Get user session rows
|
||||
query = model.table.select_where()
|
||||
query = model.table.select_where(model.start_time >= days[0])
|
||||
if guildid:
|
||||
query = query.where(userid=userid, guildid=guildid).order_by('start_time', ORDER.ASC)
|
||||
else:
|
||||
@@ -34,7 +49,6 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int,
|
||||
sessions = await query
|
||||
|
||||
# Extract quantities per-day
|
||||
day_stats = await model.study_times_between(guildid or None, userid, *days)
|
||||
for i in range(14 - len(day_stats)):
|
||||
day_stats.append(0)
|
||||
|
||||
@@ -49,9 +63,9 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int,
|
||||
timezone=str(lion.timezone),
|
||||
now=lion.now.timestamp(),
|
||||
week=week_start.timestamp(),
|
||||
daily=tuple(map(lambda n: n/3600, day_stats)),
|
||||
daily=tuple(map(int, day_stats)),
|
||||
sessions=[
|
||||
(int(session['start_time'].timestamp()), int(session['end_time'].timestamp()))
|
||||
(int(session['start_time'].timestamp()), int(session['start_time'].timestamp() + int(session['duration'])))
|
||||
for session in sessions
|
||||
],
|
||||
skin={'mode': mode}
|
||||
|
||||
@@ -1,15 +1,51 @@
|
||||
from settings import ModelData
|
||||
from settings.setting_types import BoolSetting
|
||||
"""
|
||||
Configuration settings associated to the statistics module
|
||||
"""
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
import discord
|
||||
from discord.ui.select import select, Select, SelectOption, RoleSelect
|
||||
from discord.ui.button import button, Button, ButtonStyle
|
||||
from discord.ui.text_input import TextInput, TextStyle
|
||||
|
||||
from settings import ListData, ModelData, InteractiveSetting
|
||||
from settings.setting_types import RoleListSetting, EnumSetting, ListSetting, BoolSetting, TimestampSetting
|
||||
from settings.groups import SettingGroup
|
||||
|
||||
from meta import conf, LionBot
|
||||
from meta.context import ctx_bot
|
||||
from utils.lib import tabulate
|
||||
from utils.ui import ConfigUI, FastModal, error_handler_for, ModalRetryUI
|
||||
from utils.lib import MessageArgs
|
||||
from core.data import CoreData
|
||||
from core.lion_guild import VoiceMode
|
||||
from babel.translator import ctx_translator
|
||||
|
||||
from . import babel
|
||||
from .data import StatsData, StatisticType
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
class StatsSettings(SettingGroup):
|
||||
class StatTypeSetting(EnumSetting):
|
||||
"""
|
||||
ABC setting type mixin describing an available stat type.
|
||||
"""
|
||||
_enum = StatisticType
|
||||
_outputs = {
|
||||
StatisticType.VOICE: '`Voice`',
|
||||
StatisticType.TEXT: '`Text`',
|
||||
StatisticType.ANKI: '`Anki`'
|
||||
}
|
||||
_inputs = {
|
||||
'voice': StatisticType.VOICE,
|
||||
'study': StatisticType.VOICE,
|
||||
'text': StatisticType.TEXT,
|
||||
'anki': StatisticType.ANKI
|
||||
}
|
||||
|
||||
|
||||
class StatisticsSettings(SettingGroup):
|
||||
class UserGlobalStats(ModelData, BoolSetting):
|
||||
"""
|
||||
User setting, describing whether to display global statistics or not in servers.
|
||||
@@ -20,9 +56,240 @@ class StatsSettings(SettingGroup):
|
||||
|
||||
_display_name = _p('userset:show_global_stats', "global_stats")
|
||||
_desc = _p(
|
||||
'userset:show_global_stats',
|
||||
'userset:show_global_stats|desc',
|
||||
"Whether displayed statistics include all your servers."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'userset:show_global_stats|long_desc',
|
||||
"Whether statistics commands display combined stats for all servers or just your current server."
|
||||
)
|
||||
|
||||
_model = CoreData.User
|
||||
_column = CoreData.User.show_global_stats.name
|
||||
|
||||
class SeasonStart(ModelData, TimestampSetting):
|
||||
"""
|
||||
Start of the statistics season,
|
||||
displayed on the leaderboard and used to determine activity ranks
|
||||
Time is assumed to be in set guild timezone (although supports +00 syntax)
|
||||
"""
|
||||
setting_id = 'season_start'
|
||||
|
||||
_display_name = _p('guildset:season_start', "season_start")
|
||||
_desc = _p(
|
||||
'guildset:season_start|desc',
|
||||
"Start of the current statistics season."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'guildset:season_start|long_desc',
|
||||
"Activity ranks will be determined based on tracked activity since this time, "
|
||||
"and the leaderboard will display activity since this time by default. "
|
||||
"Unset to disable seasons and use all-time statistics instead."
|
||||
)
|
||||
|
||||
_model = CoreData.Guild
|
||||
_column = CoreData.Guild.season_start.name
|
||||
# TODO: Offer to update badge ranks when this changes?
|
||||
# TODO: Don't allow future times?
|
||||
|
||||
@classmethod
|
||||
async def _timezone_from_id(cls, guildid, **kwargs):
|
||||
bot = ctx_bot.get()
|
||||
lguild = await bot.core.lions.fetch_guild(guildid)
|
||||
return lguild.timezone
|
||||
|
||||
class UnrankedRoles(ListData, RoleListSetting):
|
||||
"""
|
||||
List of roles not displayed on the leaderboard
|
||||
"""
|
||||
setting_id = 'unranked_roles'
|
||||
|
||||
_display_name = _p('guildset:unranked_roles', "unranked_roles")
|
||||
_desc = _p(
|
||||
'guildset:unranked_roles|desc',
|
||||
"Roles to exclude from the leaderboards."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'guildset:unranked_roles|long_desc',
|
||||
"When set, members with *any* of these roles will not appear on the /leaderboard ranking list."
|
||||
)
|
||||
_default = None
|
||||
|
||||
_table_interface = StatsData.unranked_roles
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'roleid'
|
||||
_order_column = 'roleid'
|
||||
|
||||
_cache = {}
|
||||
|
||||
@property
|
||||
def set_str(self):
|
||||
return "Role selector below"
|
||||
|
||||
class VisibleStats(ListData, ListSetting, InteractiveSetting):
|
||||
"""
|
||||
Which of the three stats (text, voice/study, anki) to enable in statistics views
|
||||
|
||||
Default is determined by current guild mode
|
||||
"""
|
||||
setting_id = 'visible_stats'
|
||||
|
||||
_setting = StatTypeSetting
|
||||
|
||||
_display_name = _p('guildset:visible_stats', "visible_stats")
|
||||
_desc = _p(
|
||||
'guildset:visible_stats|desc',
|
||||
"Which statistics will be visible in the statistics commands."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'guildset:visible_stats|desc',
|
||||
"Choose which statistics types to display in the leaderboard and statistics commands."
|
||||
)
|
||||
# TODO: Format VOICE as STUDY when possible?
|
||||
|
||||
_default = [
|
||||
StatisticType.VOICE,
|
||||
StatisticType.TEXT,
|
||||
]
|
||||
|
||||
_table_interface = StatsData.visible_statistics
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'stat_type'
|
||||
_order_column = 'stat_type'
|
||||
|
||||
_cache = {}
|
||||
|
||||
class DefaultStat(ModelData, StatTypeSetting):
|
||||
"""
|
||||
Which of the three stats to display by default
|
||||
"""
|
||||
setting_id = 'default_stat'
|
||||
|
||||
_display_name = _p('guildset:default_stat', "default_stat")
|
||||
_desc = _p(
|
||||
'guildset:default_stat|desc',
|
||||
"Statistic type to display by default in setting dialogues."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'guildset:default_stat|long_desc',
|
||||
"Which statistic type to display by default in setting dialogues."
|
||||
)
|
||||
|
||||
|
||||
class StatisticsConfigUI(ConfigUI):
|
||||
setting_classes = (
|
||||
StatisticsSettings.SeasonStart,
|
||||
StatisticsSettings.UnrankedRoles,
|
||||
StatisticsSettings.VisibleStats
|
||||
)
|
||||
|
||||
def __init__(self, bot: LionBot,
|
||||
guildid: int, channelid: int, **kwargs):
|
||||
super().__init__(bot, guildid, channelid, **kwargs)
|
||||
self.settings = self.bot.get_cog('StatsCog').settings
|
||||
|
||||
@select(cls=RoleSelect, placeholder='UNRANKED_ROLE_MENU', min_values=0, max_values=25)
|
||||
async def unranked_roles_menu(self, selection: discord.Interaction, selected):
|
||||
"""
|
||||
Selection menu for the "unranked_roles" setting.
|
||||
"""
|
||||
await selection.response.defer(thinking=True)
|
||||
setting = self.instances[1]
|
||||
setting.value = selected.values
|
||||
await setting.write()
|
||||
# Don't need to refresh due to instance hooks
|
||||
# await self.refresh(thinking=selection)
|
||||
await selection.delete_original_response()
|
||||
|
||||
async def unranked_roles_menu_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
self.unranked_roles_menu.placeholder = t(_p(
|
||||
'ui:statistics_config|menu:unranked_roles|placeholder',
|
||||
"Select Unranked Roles"
|
||||
))
|
||||
|
||||
@select(placeholder="STAT_TYPE_MENU", min_values=1, max_values=3)
|
||||
async def stat_type_menu(self, selection: discord.Interaction, selected):
|
||||
"""
|
||||
Selection menu for the "visible_stats" setting.
|
||||
"""
|
||||
await selection.response.defer(thinking=True)
|
||||
setting = self.instances[2]
|
||||
data = [StatisticType((value,)) for value in selected.values]
|
||||
setting.data = data
|
||||
await setting.write()
|
||||
await selection.delete_original_response()
|
||||
|
||||
async def stat_type_menu_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
setting = self.instances[2]
|
||||
value = setting.value
|
||||
|
||||
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
|
||||
if lguild.guild_mode.voice is VoiceMode.VOICE:
|
||||
voice_label = t(_p(
|
||||
'ui:statistics_config|menu:visible_stats|item:voice|mode:voice',
|
||||
"Voice Activity"
|
||||
))
|
||||
else:
|
||||
voice_label = t(_p(
|
||||
'ui:statistics_config|menu:visible_stats|item:voice|mode:study',
|
||||
"Study Statistics"
|
||||
))
|
||||
voice_option = SelectOption(
|
||||
label=voice_label,
|
||||
value=StatisticType.VOICE.value[0],
|
||||
default=(StatisticType.VOICE in value)
|
||||
)
|
||||
text_option = SelectOption(
|
||||
label=t(_p(
|
||||
'ui:statistics_config|menu:visible_stats|item:text',
|
||||
"Message Activity"
|
||||
)),
|
||||
value=StatisticType.TEXT.value[0],
|
||||
default=(StatisticType.TEXT in value)
|
||||
)
|
||||
anki_option = SelectOption(
|
||||
label=t(_p(
|
||||
'ui:statistics_config|menu:visible_stats|item:anki',
|
||||
"Anki Reviews"
|
||||
)),
|
||||
value=StatisticType.ANKI.value[0],
|
||||
default=(StatisticType.ANKI in value)
|
||||
)
|
||||
self.stat_type_menu.options = [
|
||||
voice_option, text_option, anki_option
|
||||
]
|
||||
|
||||
self.stat_type_menu.placeholder = t(_p(
|
||||
'ui:statistics_config|menu:visible_stats|placeholder',
|
||||
"Select Visible Statistics"
|
||||
))
|
||||
|
||||
async def refresh_components(self):
|
||||
await asyncio.gather(
|
||||
self.edit_button_refresh(),
|
||||
self.close_button_refresh(),
|
||||
self.reset_button_refresh(),
|
||||
self.unranked_roles_menu_refresh(),
|
||||
self.stat_type_menu_refresh(),
|
||||
)
|
||||
self._layout = [
|
||||
(self.unranked_roles_menu,),
|
||||
(self.stat_type_menu,),
|
||||
(self.edit_button, self.reset_button, self.close_button)
|
||||
]
|
||||
|
||||
async def make_message(self):
|
||||
t = self.bot.translator.t
|
||||
title = t(_p(
|
||||
'ui:statistics_config|embed|title',
|
||||
"Statistics Configuration Panel"
|
||||
))
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=title
|
||||
)
|
||||
for setting in self.instances:
|
||||
embed.add_field(**setting.embed_field, inline=False)
|
||||
return MessageArgs(embed=embed)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .profile import ProfileUI
|
||||
from .weeklymonthly import WeeklyMonthlyUI
|
||||
from .leaderboard import LeaderboardUI
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
from enum import IntEnum
|
||||
import asyncio
|
||||
|
||||
import discord
|
||||
from discord.ui.button import ButtonStyle, button, Button
|
||||
from discord.ui.select import select, Select, SelectOption
|
||||
|
||||
from gui.base import CardMode
|
||||
|
||||
from meta import LionBot, conf
|
||||
from utils.lib import MessageArgs
|
||||
from utils.ui import input
|
||||
from core.lion_guild import VoiceMode
|
||||
from babel.translator import ctx_translator, LazyStr
|
||||
|
||||
from ..data import StatsData
|
||||
from ..graphics.leaderboard import get_leaderboard_card
|
||||
from .. import babel
|
||||
|
||||
from .base import StatsUI
|
||||
|
||||
|
||||
class LBPeriod(IntEnum):
|
||||
SEASON = 0
|
||||
DAY = 1
|
||||
WEEK = 2
|
||||
MONTH = 3
|
||||
ALLTIME = 4
|
||||
|
||||
|
||||
class StatType(IntEnum):
|
||||
VOICE = 0
|
||||
TEXT = 1
|
||||
ANKI = 2
|
||||
|
||||
|
||||
class LeaderboardUI(StatsUI):
|
||||
page_size = 10
|
||||
|
||||
def __init__(self, bot, user, guild, **kwargs):
|
||||
super().__init__(bot, user, guild, **kwargs)
|
||||
self.data: StatsData = bot.get_cog('StatsCog').data
|
||||
|
||||
# ----- Constants initialised on run -----
|
||||
self.show_season = None
|
||||
self.period_starts = None
|
||||
|
||||
# ----- UI state -----
|
||||
# Whether the leaderboard is focused on the calling member
|
||||
self.focused = True
|
||||
|
||||
# Current visible page number
|
||||
self.pagen = 0
|
||||
|
||||
# Current stat type
|
||||
self.stat_type = StatType.VOICE
|
||||
|
||||
# Start of the current period
|
||||
self.current_period = LBPeriod.SEASON
|
||||
|
||||
# Current rendered leaderboard card, if it exists
|
||||
self.card = None
|
||||
|
||||
# ----- Cached and on-demand data -----
|
||||
# Cache of the full leaderboards for each type and period, populated on demand
|
||||
# (type, period) -> List[(userid, duration)]
|
||||
self.lb_data = {}
|
||||
|
||||
# Cache of the cards already displayed
|
||||
# (type, period) -> (pagen -> Optional[Future[Card]])
|
||||
self.cache = {}
|
||||
|
||||
async def run(self, interaction: discord.Interaction):
|
||||
self._original = interaction
|
||||
|
||||
# Fetch guild data and populate period starts
|
||||
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
|
||||
periods = {}
|
||||
self.show_season = bool(lguild.data.season_start)
|
||||
if self.show_season:
|
||||
periods[LBPeriod.SEASON] = lguild.data.season_start
|
||||
self.current_period = LBPeriod.SEASON
|
||||
else:
|
||||
self.current_period = LBPeriod.ALLTIME
|
||||
periods[LBPeriod.DAY] = lguild.today
|
||||
periods[LBPeriod.WEEK] = lguild.week_start
|
||||
periods[LBPeriod.MONTH] = lguild.month_start
|
||||
self.period_starts = periods
|
||||
|
||||
self.focused = True
|
||||
await self.refresh()
|
||||
|
||||
async def focus_caller(self):
|
||||
"""
|
||||
Focus the calling user, if possible.
|
||||
"""
|
||||
self.focused = True
|
||||
data = await self.current_data()
|
||||
if data:
|
||||
caller_index = next((i for i, (uid, _) in enumerate(data) if uid == self.userid), None)
|
||||
if caller_index is not None:
|
||||
self.pagen = caller_index // self.page_size
|
||||
|
||||
async def _fetch_lb_data(self, stat_type, period) -> list[tuple[int, int]]:
|
||||
"""
|
||||
Worker for `fetch_lb_data`.
|
||||
"""
|
||||
if stat_type is StatType.VOICE:
|
||||
if period is LBPeriod.ALLTIME:
|
||||
data = await self.data.VoiceSessionStats.leaderboard_all(self.guildid)
|
||||
elif (period_start := self.period_starts.get(period, None)) is None:
|
||||
raise ValueError("Uninitialised period requested!")
|
||||
else:
|
||||
data = await self.data.VoiceSessionStats.leaderboard_since(
|
||||
self.guildid, period_start
|
||||
)
|
||||
elif stat_type is StatType.TEXT:
|
||||
if period is LBPeriod.ALLTIME:
|
||||
data = await self.data.MemberExp.leaderboard_all(self.guildid)
|
||||
elif (period_start := self.period_starts.get(period, None)) is None:
|
||||
raise ValueError("Uninitialised period requested!")
|
||||
else:
|
||||
data = await self.data.MemberExp.leaderboard_since(
|
||||
self.guildid, period_start
|
||||
)
|
||||
# TODO: Handle removing members in invisible roles
|
||||
return data
|
||||
|
||||
async def fetch_lb_data(self, stat_type, period):
|
||||
"""
|
||||
Fetch the leaderboard data for the given type and period.
|
||||
|
||||
Uses cached futures so that requests are not repeated.
|
||||
"""
|
||||
key = (stat_type, period)
|
||||
future = self.lb_data.get(key, None)
|
||||
if future is not None and not future.cancelled():
|
||||
result = await future
|
||||
else:
|
||||
future = asyncio.create_task(self._fetch_lb_data(*key))
|
||||
self.lb_data[key] = future
|
||||
result = await future
|
||||
return result
|
||||
|
||||
async def current_data(self):
|
||||
"""
|
||||
Helper method to retrieve the leaderboard data for the current mode.
|
||||
"""
|
||||
return await self.fetch_lb_data(self.stat_type, self.current_period)
|
||||
|
||||
async def _render_card(self, stat_type, period, pagen, data):
|
||||
"""
|
||||
Render worker for the given leaderboard page.
|
||||
"""
|
||||
if data:
|
||||
# Calculate page data
|
||||
page_starts_at = pagen * self.page_size
|
||||
userids, times = zip(*data[page_starts_at:page_starts_at + self.page_size])
|
||||
positions = range(page_starts_at + 1, page_starts_at + self.page_size + 1)
|
||||
|
||||
page_data = zip(userids, positions, times)
|
||||
if self.stat_type is StatType.VOICE:
|
||||
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
|
||||
if lguild.guild_mode.voice is VoiceMode.VOICE:
|
||||
mode = CardMode.VOICE
|
||||
else:
|
||||
mode = CardMode.STUDY
|
||||
elif self.stat_type is StatType.TEXT:
|
||||
mode = CardMode.TEXT
|
||||
elif self.stat_type is StatType.ANKI:
|
||||
mode = CardMode.ANKI
|
||||
card = await get_leaderboard_card(
|
||||
self.bot, self.userid, self.guildid,
|
||||
mode,
|
||||
list(page_data)
|
||||
)
|
||||
await card.render()
|
||||
return card
|
||||
else:
|
||||
# Leaderboard is empty
|
||||
return None
|
||||
|
||||
async def fetch_page(self, stat_type, period, pagen):
|
||||
"""
|
||||
Fetch the requested leaderboard page as a rendered LeaderboardCard.
|
||||
|
||||
Applies cache where possible.
|
||||
"""
|
||||
lb_data = await self.fetch_lb_data(stat_type, period)
|
||||
if lb_data:
|
||||
pagen %= (len(lb_data) // self.page_size) + 1
|
||||
else:
|
||||
pagen = 0
|
||||
key = (stat_type, period, pagen)
|
||||
if (future := self.cache.get(key, None)) is not None and not future.cancelled():
|
||||
card = await future
|
||||
else:
|
||||
future = asyncio.create_task(self._render_card(
|
||||
stat_type,
|
||||
period,
|
||||
pagen,
|
||||
lb_data
|
||||
))
|
||||
self.cache[key] = future
|
||||
card = await future
|
||||
return card
|
||||
|
||||
# UI interface
|
||||
@select(placeholder="Select Activity Type")
|
||||
async def stat_menu(self, selection: discord.Interaction, selected):
|
||||
if selected.values:
|
||||
await selection.response.defer(thinking=True)
|
||||
self.stat_type = StatType(int(selected.values[0]))
|
||||
self.focused = True
|
||||
await self.refresh(thinking=selection)
|
||||
|
||||
async def stat_menu_refresh(self):
|
||||
# TODO: Customise based on configuration
|
||||
menu = self.stat_menu
|
||||
options = []
|
||||
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
|
||||
if lguild.guild_mode.voice is VoiceMode.VOICE:
|
||||
options.append(
|
||||
SelectOption(
|
||||
label="Voice Activity",
|
||||
value=str(StatType.VOICE.value)
|
||||
)
|
||||
)
|
||||
else:
|
||||
options.append(
|
||||
SelectOption(
|
||||
label="Study Statistics",
|
||||
value=str(StatType.VOICE.value)
|
||||
)
|
||||
)
|
||||
|
||||
options.append(
|
||||
SelectOption(
|
||||
label="Message Activity",
|
||||
value=str(StatType.TEXT.value)
|
||||
)
|
||||
)
|
||||
options.append(
|
||||
SelectOption(
|
||||
label="Anki Cards Reviewed",
|
||||
value=str(StatType.ANKI.value)
|
||||
)
|
||||
)
|
||||
menu.options = options
|
||||
|
||||
@button(label="This Season", style=ButtonStyle.grey)
|
||||
async def season_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.current_period = LBPeriod.SEASON
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="Today", style=ButtonStyle.grey)
|
||||
async def day_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.current_period = LBPeriod.DAY
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="This Week", style=ButtonStyle.grey)
|
||||
async def week_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.current_period = LBPeriod.WEEK
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="This Month", style=ButtonStyle.grey)
|
||||
async def month_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.current_period = LBPeriod.MONTH
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="All Time", style=ButtonStyle.grey)
|
||||
async def alltime_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.current_period = LBPeriod.ALLTIME
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(emoji=conf.emojis.backward, style=ButtonStyle.grey)
|
||||
async def prev_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.pagen -= 1
|
||||
self.focused = False
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="Jump", style=ButtonStyle.blurple)
|
||||
async def jump_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Jump-to-page button.
|
||||
Loads a page-switch dialogue.
|
||||
"""
|
||||
try:
|
||||
interaction, value = await input(
|
||||
press,
|
||||
title="Jump to page",
|
||||
question="Page number to jump to"
|
||||
)
|
||||
value = value.strip()
|
||||
except asyncio.TimeoutError:
|
||||
return
|
||||
|
||||
if not value.lstrip('- ').isdigit():
|
||||
error_embed = discord.Embed(
|
||||
title="Invalid page number, please try again!",
|
||||
colour=discord.Colour.brand_red()
|
||||
)
|
||||
await interaction.response.send_message(embed=error_embed, ephemeral=True)
|
||||
else:
|
||||
await interaction.response.defer(thinking=True)
|
||||
pagen = int(value.lstrip('- '))
|
||||
if value.startswith('-'):
|
||||
pagen = -1 * pagen
|
||||
elif pagen > 0:
|
||||
pagen = pagen - 1
|
||||
self.pagen = pagen
|
||||
self.focused = False
|
||||
await self.refresh(thinking=interaction)
|
||||
|
||||
async def jump_button_refresh(self):
|
||||
component = self.jump_button
|
||||
|
||||
data = await self.current_data()
|
||||
if not data:
|
||||
# Component should be hidden
|
||||
component.label = "-/-"
|
||||
component.disabled = True
|
||||
else:
|
||||
page_count = (len(data) // self.page_size) + 1
|
||||
pagen = self.pagen % page_count
|
||||
component.label = "{}/{}".format(pagen + 1, page_count)
|
||||
component.disabled = (page_count <= 1)
|
||||
|
||||
@button(emoji=conf.emojis.forward, style=ButtonStyle.grey)
|
||||
async def next_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.pagen += 1
|
||||
self.focused = False
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
async def make_message(self) -> MessageArgs:
|
||||
"""
|
||||
Generate UI message arguments from stored data
|
||||
"""
|
||||
if self.card is not None:
|
||||
args = MessageArgs(
|
||||
embed=None,
|
||||
file=self.card.as_file('leaderboard.png')
|
||||
)
|
||||
else:
|
||||
# TOLOCALISE:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title="Empty Leaderboard!",
|
||||
description=(
|
||||
"There has been no activity of this type in this period!"
|
||||
)
|
||||
)
|
||||
args = MessageArgs(embed=embed, files=[])
|
||||
return args
|
||||
|
||||
async def refresh_components(self):
|
||||
await asyncio.gather(
|
||||
self.jump_button_refresh(),
|
||||
self.close_button_refresh(),
|
||||
self.stat_menu_refresh()
|
||||
)
|
||||
|
||||
# Compute period row
|
||||
period_buttons = {
|
||||
LBPeriod.DAY: self.day_button,
|
||||
LBPeriod.WEEK: self.week_button,
|
||||
LBPeriod.MONTH: self.month_button
|
||||
}
|
||||
if self.show_season:
|
||||
period_buttons[LBPeriod.SEASON] = self.season_button
|
||||
else:
|
||||
period_buttons[LBPeriod.ALLTIME] = self.alltime_button
|
||||
|
||||
for period, component in period_buttons.items():
|
||||
if period is self.current_period:
|
||||
component.style = ButtonStyle.blurple
|
||||
else:
|
||||
component.style = ButtonStyle.grey
|
||||
|
||||
period_row = tuple(period_buttons.values())
|
||||
|
||||
# Compute page row
|
||||
data = await self.current_data()
|
||||
multipage = len(data) > self.page_size
|
||||
if multipage:
|
||||
page_row = (
|
||||
self.prev_button, self.jump_button, self.close_button, self.next_button
|
||||
)
|
||||
else:
|
||||
period_row = (*period_row, self.close_button)
|
||||
page_row = ()
|
||||
|
||||
self._layout = [
|
||||
(self.stat_menu,),
|
||||
period_row,
|
||||
page_row
|
||||
]
|
||||
|
||||
async def reload(self):
|
||||
"""
|
||||
Reload UI data, applying cache where possible.
|
||||
"""
|
||||
if self.focused:
|
||||
await self.focus_caller()
|
||||
self.card = await self.fetch_page(
|
||||
self.stat_type,
|
||||
self.current_period,
|
||||
self.pagen
|
||||
)
|
||||
|
||||
@@ -13,8 +13,10 @@ from utils.lib import MessageArgs
|
||||
from utils.ui import LeoUI, ModalRetryUI, FastModal, error_handler_for
|
||||
from babel.translator import ctx_translator
|
||||
from gui.cards import ProfileCard, StatsCard
|
||||
from gui.base import CardMode
|
||||
|
||||
from ..graphics.stats import get_stats_card
|
||||
from ..graphics.profile import get_profile_card
|
||||
from ..data import StatsData
|
||||
from .. import babel
|
||||
|
||||
@@ -101,6 +103,16 @@ class StatType(IntEnum):
|
||||
)
|
||||
return name
|
||||
|
||||
@property
|
||||
def card_mode(self):
|
||||
# TODO: Need to support VOICE separately from STUDY
|
||||
if self is self.VOICE:
|
||||
return CardMode.VOICE
|
||||
elif self is self.TEXT:
|
||||
return CardMode.TEXT
|
||||
elif self is self.ANKI:
|
||||
return CardMode.ANKI
|
||||
|
||||
|
||||
class ProfileUI(StatsUI):
|
||||
def __init__(self, bot, user, guild, **kwargs):
|
||||
@@ -109,6 +121,7 @@ class ProfileUI(StatsUI):
|
||||
# State
|
||||
self._stat_type = StatType.VOICE
|
||||
self._showing_stats = False
|
||||
self._stat_message = None
|
||||
|
||||
# Card data for rendering
|
||||
self._profile_card: Optional[ProfileCard] = None
|
||||
@@ -181,7 +194,11 @@ class ProfileUI(StatsUI):
|
||||
await press.response.send_modal(modal)
|
||||
|
||||
async def edit_button_refresh(self):
|
||||
...
|
||||
t = self.bot.translator.t
|
||||
self.edit_button.label = t(_p(
|
||||
'ui:profile_card|button:edit|label',
|
||||
"Edit Profile Badges"
|
||||
))
|
||||
|
||||
@button(label="Show Statistics", style=ButtonStyle.blurple)
|
||||
async def stats_button(self, press: discord.Interaction, pressed: Button):
|
||||
@@ -189,12 +206,8 @@ class ProfileUI(StatsUI):
|
||||
Press to show or hide the statistics panel.
|
||||
"""
|
||||
self._showing_stats = not self._showing_stats
|
||||
if self._stats_card or not self._showing_stats:
|
||||
await press.response.defer()
|
||||
await self.refresh()
|
||||
else:
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
await self.refresh(thinking=press)
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
async def stats_button_refresh(self):
|
||||
button = self.stats_button
|
||||
@@ -243,6 +256,19 @@ class ProfileUI(StatsUI):
|
||||
else:
|
||||
button.label = "Global Statistics"
|
||||
|
||||
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
|
||||
async def close_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Delete the output message and close the UI.
|
||||
"""
|
||||
await press.response.defer()
|
||||
await self._original.delete_original_response()
|
||||
if self._stat_message is not None:
|
||||
await self._stat_message.delete()
|
||||
self._stat_message = None
|
||||
self._original = None
|
||||
await self.close()
|
||||
|
||||
async def refresh_components(self):
|
||||
"""
|
||||
Refresh each UI component, and the overall layout.
|
||||
@@ -268,7 +294,7 @@ class ProfileUI(StatsUI):
|
||||
"""
|
||||
Create and render the profile card.
|
||||
"""
|
||||
card = await get_stats_card(self.bot, self.userid, self.guildid)
|
||||
card = await get_stats_card(self.bot, self.userid, self.guildid, self._stat_type.card_mode)
|
||||
await card.render()
|
||||
self._stats_card = card
|
||||
return card
|
||||
@@ -277,12 +303,10 @@ class ProfileUI(StatsUI):
|
||||
"""
|
||||
Create and render the XP and stats cards.
|
||||
"""
|
||||
args = await ProfileCard.sample_args(None)
|
||||
data: StatsData = self.bot.get_cog('StatsCog').data
|
||||
args |= {'badges': await data.ProfileTag.fetch_tags(self.guildid, self.userid)}
|
||||
card = ProfileCard(**args)
|
||||
await card.render()
|
||||
self._profile_card = card
|
||||
card = await get_profile_card(self.bot, self.userid, self.guildid)
|
||||
if card:
|
||||
await card.render()
|
||||
self._profile_card = card
|
||||
return card
|
||||
|
||||
async def reload(self):
|
||||
@@ -302,16 +326,39 @@ class ProfileUI(StatsUI):
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
async def redraw(self, thinking: Optional[discord.Interaction] = None):
|
||||
"""
|
||||
Redraw the UI.
|
||||
|
||||
If a thinking interaction is provided,
|
||||
deletes the response while redrawing.
|
||||
"""
|
||||
profile_args, stat_args = await self.make_message()
|
||||
if thinking is not None and not thinking.is_expired() and thinking.response.is_done():
|
||||
asyncio.create_task(thinking.delete_original_response())
|
||||
if stat_args is not None:
|
||||
send_task = asyncio.create_task(self._original.edit_original_response(**profile_args.edit_args, view=None))
|
||||
if self._stat_message is None:
|
||||
self._stat_message = await self._original.followup.send(**stat_args.send_args, view=self)
|
||||
else:
|
||||
await self._stat_message.edit(**stat_args.edit_args, view=self)
|
||||
else:
|
||||
send_task = asyncio.create_task(self._original.edit_original_response(**profile_args.edit_args, view=self))
|
||||
if self._stat_message is not None:
|
||||
await self._stat_message.delete()
|
||||
self._stat_message = None
|
||||
await send_task
|
||||
|
||||
async def make_message(self) -> MessageArgs:
|
||||
"""
|
||||
Make the message arguments. Apply cache where possible.
|
||||
"""
|
||||
# Build the final message arguments
|
||||
files = []
|
||||
files.append(self._profile_card.as_file('profile.png'))
|
||||
profile_args = MessageArgs(file=self._profile_card.as_file('profile.png'))
|
||||
if self._showing_stats:
|
||||
files.append(self._stats_card.as_file('stats.png'))
|
||||
return MessageArgs(files=files)
|
||||
stats_args = MessageArgs(file=self._stats_card.as_file('stats.png'))
|
||||
else:
|
||||
stats_args = None
|
||||
return (profile_args, stats_args)
|
||||
|
||||
async def run(self, interaction: discord.Interaction):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user