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 meta import LionBot, LionCog, LionContext
|
||||||
from utils.lib import error_embed
|
from utils.lib import error_embed
|
||||||
from utils.ui import LeoUI, AButton
|
from utils.ui import LeoUI, AButton
|
||||||
|
from wards import low_management
|
||||||
|
|
||||||
from . import babel
|
from . import babel
|
||||||
from .data import StatsData
|
from .data import StatsData
|
||||||
from .ui import ProfileUI, WeeklyMonthlyUI
|
from .ui import ProfileUI, WeeklyMonthlyUI, LeaderboardUI
|
||||||
from .settings import StatsSettings
|
from .settings import StatisticsSettings, StatisticsConfigUI
|
||||||
|
|
||||||
_p = babel._p
|
_p = babel._p
|
||||||
|
|
||||||
@@ -25,12 +26,17 @@ class StatsCog(LionCog):
|
|||||||
def __init__(self, bot: LionBot):
|
def __init__(self, bot: LionBot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.data = bot.db.load_registry(StatsData())
|
self.data = bot.db.load_registry(StatsData())
|
||||||
self.settings = StatsSettings
|
self.settings = StatisticsSettings()
|
||||||
|
|
||||||
async def cog_load(self):
|
async def cog_load(self):
|
||||||
await self.data.init()
|
await self.data.init()
|
||||||
|
|
||||||
self.bot.core.user_config.register_model_setting(self.settings.UserGlobalStats)
|
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(
|
@cmds.hybrid_command(
|
||||||
name=_p('cmd:me', "me"),
|
name=_p('cmd:me', "me"),
|
||||||
@@ -68,4 +74,67 @@ class StatsCog(LionCog):
|
|||||||
)
|
)
|
||||||
@appcmds.guild_only
|
@appcmds.guild_only
|
||||||
async def leaderboard_cmd(self, ctx: LionContext):
|
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 typing import Optional, Iterable
|
||||||
|
from enum import Enum
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from psycopg import sql
|
from psycopg import sql
|
||||||
|
|
||||||
from data import RowModel, Registry, Table
|
from data import RowModel, Registry, Table, RegisterEnum
|
||||||
from data.columns import Integer, String, Timestamp, Bool
|
from data.columns import Integer, String, Timestamp, Bool, Column
|
||||||
|
|
||||||
from utils.lib import utc_now
|
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):
|
class StatsData(Registry):
|
||||||
|
StatisticType = RegisterEnum(StatisticType, name='StatisticType')
|
||||||
|
ExpType = RegisterEnum(ExpType, name='ExperienceType')
|
||||||
|
|
||||||
class VoiceSessionStats(RowModel):
|
class VoiceSessionStats(RowModel):
|
||||||
"""
|
"""
|
||||||
View containing voice session statistics.
|
View containing voice session statistics.
|
||||||
@@ -21,7 +59,7 @@ class StatsData(Registry):
|
|||||||
guildid,
|
guildid,
|
||||||
start_time,
|
start_time,
|
||||||
duration,
|
duration,
|
||||||
(start_time + duration * interval '1 second') AS end_time
|
(timezone('UTC', start_time) + duration * interval '1 second') AS end_time
|
||||||
FROM session_history
|
FROM session_history
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
@@ -115,6 +153,321 @@ class StatsData(Registry):
|
|||||||
)
|
)
|
||||||
return [r['stime'] or 0 for r in await cursor.fetchall()]
|
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):
|
class ProfileTag(RowModel):
|
||||||
"""
|
"""
|
||||||
Schema
|
Schema
|
||||||
@@ -256,3 +609,6 @@ class StatsData(Registry):
|
|||||||
content = String()
|
content = String()
|
||||||
completed = Bool()
|
completed = Bool()
|
||||||
_timestamp = Timestamp()
|
_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 meta import LionBot
|
||||||
from gui.cards import WeeklyGoalCard, MonthlyGoalCard
|
from gui.cards import WeeklyGoalCard, MonthlyGoalCard
|
||||||
from gui.base import CardMode
|
from gui.base import CardMode
|
||||||
|
from tracking.text.data import TextTrackerData
|
||||||
|
|
||||||
from ..data import StatsData
|
from ..data import StatsData
|
||||||
from ..lib import extract_weekid, extract_monthid, apply_week_offset, apply_month_offset
|
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}
|
key = {'guildid': guildid or 0, 'userid': userid, 'monthid': periodid}
|
||||||
|
|
||||||
# Extract goals and tasks
|
# 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)
|
task_rows = await tasks_model.fetch_where(**key)
|
||||||
tasks = [(i, row.content, bool(row.completed)) for i, row in enumerate(task_rows)]
|
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
|
tasks_completed = results[0]['total'] if results else 0
|
||||||
|
|
||||||
# Set and compute correct middle goal column
|
# Set and compute correct middle goal column
|
||||||
# if mode in (CardMode.VOICE, CardMode.STUDY):
|
if mode in (CardMode.VOICE, CardMode.STUDY):
|
||||||
if True:
|
|
||||||
model = data.VoiceSessionStats
|
model = data.VoiceSessionStats
|
||||||
middle_completed = (await model.study_times_between(guildid or None, userid, start, end))[0]
|
middle_completed = (await model.study_times_between(guildid or None, userid, start, end))[0]
|
||||||
middle_goal = goals['study_goal']
|
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
|
# Compute schedule session progress
|
||||||
# TODO
|
# 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 meta import LionBot
|
||||||
from gui.cards import MonthlyStatsCard
|
from gui.cards import MonthlyStatsCard
|
||||||
from gui.base import CardMode
|
from gui.base import CardMode
|
||||||
|
from tracking.text.data import TextTrackerData
|
||||||
|
|
||||||
from ..data import StatsData
|
from ..data import StatsData
|
||||||
from ..lib import apply_month_offset
|
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
|
[0]*(calendar.monthrange(month.year, month.month)[1]) for month in months
|
||||||
]
|
]
|
||||||
|
|
||||||
# TODO: Select model based on card mode
|
if mode is CardMode.VOICE:
|
||||||
model = data.VoiceSessionStats
|
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
|
# Get first session
|
||||||
query = model.table.select_where().order_by('start_time', ORDER.ASC).limit(1)
|
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
|
longest_streak = 0
|
||||||
else:
|
else:
|
||||||
first_day = first_session.replace(hour=0, minute=0, second=0, microsecond=0)
|
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
|
# 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
|
end_of_req = target_end if offset else today
|
||||||
day = first_day
|
day = first_day
|
||||||
while day <= end_of_req:
|
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)
|
requests.append(day)
|
||||||
|
|
||||||
# Request times between requested days
|
# 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
|
# Compute current streak and longest streak
|
||||||
current_streak = 0
|
current_streak = 0
|
||||||
@@ -79,7 +95,10 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
|
|||||||
if day < months[0]:
|
if day < months[0]:
|
||||||
break
|
break
|
||||||
i = offsets[(day.year, day.month)]
|
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
|
# Get member profile
|
||||||
if user:
|
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 meta import LionBot
|
||||||
from gui.cards import StatsCard
|
from gui.cards import StatsCard
|
||||||
|
from gui.base import CardMode
|
||||||
|
|
||||||
from ..data import StatsData
|
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
|
data: StatsData = bot.get_cog('StatsCog').data
|
||||||
|
|
||||||
# TODO: Workouts
|
# TODO: Workouts
|
||||||
# TODO: Leaderboard rankings
|
# TODO: Leaderboard rankings for this season or all time
|
||||||
guildid = guildid or 0
|
guildid = guildid or 0
|
||||||
|
|
||||||
lion = await bot.core.lions.fetch_member(guildid, userid)
|
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
|
# Extract the study times for each period
|
||||||
study_times = await data.VoiceSessionStats.study_times_since(guildid, userid, *period_timestamps)
|
if mode in (CardMode.STUDY, CardMode.VOICE):
|
||||||
print("Study times", study_times)
|
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
|
# Calculate streak data by requesting times per day
|
||||||
# First calculate starting timestamps for each day
|
# First calculate starting timestamps for each day
|
||||||
days = list(range(0, today.day + 2))
|
days = list(range(0, today.day + 2))
|
||||||
day_timestamps = [month_start + timedelta(days=day - 1) for day in days]
|
day_timestamps = [month_start + timedelta(days=day - 1) for day in days]
|
||||||
study_times = await data.VoiceSessionStats.study_times_between(guildid, userid, *day_timestamps)
|
study_times_month = await ref_between(*refkey, *day_timestamps)
|
||||||
print("Study times", study_times)
|
|
||||||
|
|
||||||
# Then extract streak tuples
|
# Then extract streak tuples
|
||||||
streaks = []
|
streaks = []
|
||||||
streak_start = None
|
streak_start = None
|
||||||
for day, stime in zip(days, study_times):
|
for day, stime in zip(days, study_times_month):
|
||||||
stime = stime or 0
|
stime = stime or 0
|
||||||
if stime > 0 and streak_start is None:
|
if stime > 0 and streak_start is None:
|
||||||
streak_start = day
|
streak_start = day
|
||||||
@@ -59,5 +80,6 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int):
|
|||||||
list(reversed(study_times)),
|
list(reversed(study_times)),
|
||||||
100,
|
100,
|
||||||
streaks,
|
streaks,
|
||||||
|
skin={'mode': mode}
|
||||||
)
|
)
|
||||||
return card
|
return card
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from data import ORDER
|
|||||||
from meta import LionBot
|
from meta import LionBot
|
||||||
from gui.cards import WeeklyStatsCard
|
from gui.cards import WeeklyStatsCard
|
||||||
from gui.base import CardMode
|
from gui.base import CardMode
|
||||||
|
from tracking.text.data import TextTrackerData
|
||||||
|
|
||||||
from ..data import StatsData
|
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)
|
user = await bot.fetch_user(userid)
|
||||||
today = lion.today
|
today = lion.today
|
||||||
week_start = today - timedelta(days=today.weekday()) - timedelta(weeks=offset)
|
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
|
# 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
|
# Get user session rows
|
||||||
query = model.table.select_where()
|
query = model.table.select_where(model.start_time >= days[0])
|
||||||
if guildid:
|
if guildid:
|
||||||
query = query.where(userid=userid, guildid=guildid).order_by('start_time', ORDER.ASC)
|
query = query.where(userid=userid, guildid=guildid).order_by('start_time', ORDER.ASC)
|
||||||
else:
|
else:
|
||||||
@@ -34,7 +49,6 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int,
|
|||||||
sessions = await query
|
sessions = await query
|
||||||
|
|
||||||
# Extract quantities per-day
|
# Extract quantities per-day
|
||||||
day_stats = await model.study_times_between(guildid or None, userid, *days)
|
|
||||||
for i in range(14 - len(day_stats)):
|
for i in range(14 - len(day_stats)):
|
||||||
day_stats.append(0)
|
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),
|
timezone=str(lion.timezone),
|
||||||
now=lion.now.timestamp(),
|
now=lion.now.timestamp(),
|
||||||
week=week_start.timestamp(),
|
week=week_start.timestamp(),
|
||||||
daily=tuple(map(lambda n: n/3600, day_stats)),
|
daily=tuple(map(int, day_stats)),
|
||||||
sessions=[
|
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
|
for session in sessions
|
||||||
],
|
],
|
||||||
skin={'mode': mode}
|
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 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.data import CoreData
|
||||||
|
from core.lion_guild import VoiceMode
|
||||||
|
from babel.translator import ctx_translator
|
||||||
|
|
||||||
from . import babel
|
from . import babel
|
||||||
|
from .data import StatsData, StatisticType
|
||||||
|
|
||||||
_p = babel._p
|
_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):
|
class UserGlobalStats(ModelData, BoolSetting):
|
||||||
"""
|
"""
|
||||||
User setting, describing whether to display global statistics or not in servers.
|
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")
|
_display_name = _p('userset:show_global_stats', "global_stats")
|
||||||
_desc = _p(
|
_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."
|
"Whether statistics commands display combined stats for all servers or just your current server."
|
||||||
)
|
)
|
||||||
|
|
||||||
_model = CoreData.User
|
_model = CoreData.User
|
||||||
_column = CoreData.User.show_global_stats.name
|
_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 .profile import ProfileUI
|
||||||
from .weeklymonthly import WeeklyMonthlyUI
|
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 utils.ui import LeoUI, ModalRetryUI, FastModal, error_handler_for
|
||||||
from babel.translator import ctx_translator
|
from babel.translator import ctx_translator
|
||||||
from gui.cards import ProfileCard, StatsCard
|
from gui.cards import ProfileCard, StatsCard
|
||||||
|
from gui.base import CardMode
|
||||||
|
|
||||||
from ..graphics.stats import get_stats_card
|
from ..graphics.stats import get_stats_card
|
||||||
|
from ..graphics.profile import get_profile_card
|
||||||
from ..data import StatsData
|
from ..data import StatsData
|
||||||
from .. import babel
|
from .. import babel
|
||||||
|
|
||||||
@@ -101,6 +103,16 @@ class StatType(IntEnum):
|
|||||||
)
|
)
|
||||||
return name
|
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):
|
class ProfileUI(StatsUI):
|
||||||
def __init__(self, bot, user, guild, **kwargs):
|
def __init__(self, bot, user, guild, **kwargs):
|
||||||
@@ -109,6 +121,7 @@ class ProfileUI(StatsUI):
|
|||||||
# State
|
# State
|
||||||
self._stat_type = StatType.VOICE
|
self._stat_type = StatType.VOICE
|
||||||
self._showing_stats = False
|
self._showing_stats = False
|
||||||
|
self._stat_message = None
|
||||||
|
|
||||||
# Card data for rendering
|
# Card data for rendering
|
||||||
self._profile_card: Optional[ProfileCard] = None
|
self._profile_card: Optional[ProfileCard] = None
|
||||||
@@ -181,7 +194,11 @@ class ProfileUI(StatsUI):
|
|||||||
await press.response.send_modal(modal)
|
await press.response.send_modal(modal)
|
||||||
|
|
||||||
async def edit_button_refresh(self):
|
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)
|
@button(label="Show Statistics", style=ButtonStyle.blurple)
|
||||||
async def stats_button(self, press: discord.Interaction, pressed: Button):
|
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.
|
Press to show or hide the statistics panel.
|
||||||
"""
|
"""
|
||||||
self._showing_stats = not self._showing_stats
|
self._showing_stats = not self._showing_stats
|
||||||
if self._stats_card or not self._showing_stats:
|
await press.response.defer(thinking=True, ephemeral=True)
|
||||||
await press.response.defer()
|
await self.refresh(thinking=press)
|
||||||
await self.refresh()
|
|
||||||
else:
|
|
||||||
await press.response.defer(thinking=True, ephemeral=True)
|
|
||||||
await self.refresh(thinking=press)
|
|
||||||
|
|
||||||
async def stats_button_refresh(self):
|
async def stats_button_refresh(self):
|
||||||
button = self.stats_button
|
button = self.stats_button
|
||||||
@@ -243,6 +256,19 @@ class ProfileUI(StatsUI):
|
|||||||
else:
|
else:
|
||||||
button.label = "Global Statistics"
|
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):
|
async def refresh_components(self):
|
||||||
"""
|
"""
|
||||||
Refresh each UI component, and the overall layout.
|
Refresh each UI component, and the overall layout.
|
||||||
@@ -268,7 +294,7 @@ class ProfileUI(StatsUI):
|
|||||||
"""
|
"""
|
||||||
Create and render the profile card.
|
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()
|
await card.render()
|
||||||
self._stats_card = card
|
self._stats_card = card
|
||||||
return card
|
return card
|
||||||
@@ -277,12 +303,10 @@ class ProfileUI(StatsUI):
|
|||||||
"""
|
"""
|
||||||
Create and render the XP and stats cards.
|
Create and render the XP and stats cards.
|
||||||
"""
|
"""
|
||||||
args = await ProfileCard.sample_args(None)
|
card = await get_profile_card(self.bot, self.userid, self.guildid)
|
||||||
data: StatsData = self.bot.get_cog('StatsCog').data
|
if card:
|
||||||
args |= {'badges': await data.ProfileTag.fetch_tags(self.guildid, self.userid)}
|
await card.render()
|
||||||
card = ProfileCard(**args)
|
self._profile_card = card
|
||||||
await card.render()
|
|
||||||
self._profile_card = card
|
|
||||||
return card
|
return card
|
||||||
|
|
||||||
async def reload(self):
|
async def reload(self):
|
||||||
@@ -302,16 +326,39 @@ class ProfileUI(StatsUI):
|
|||||||
if tasks:
|
if tasks:
|
||||||
await asyncio.gather(*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:
|
async def make_message(self) -> MessageArgs:
|
||||||
"""
|
"""
|
||||||
Make the message arguments. Apply cache where possible.
|
Make the message arguments. Apply cache where possible.
|
||||||
"""
|
"""
|
||||||
# Build the final message arguments
|
profile_args = MessageArgs(file=self._profile_card.as_file('profile.png'))
|
||||||
files = []
|
|
||||||
files.append(self._profile_card.as_file('profile.png'))
|
|
||||||
if self._showing_stats:
|
if self._showing_stats:
|
||||||
files.append(self._stats_card.as_file('stats.png'))
|
stats_args = MessageArgs(file=self._stats_card.as_file('stats.png'))
|
||||||
return MessageArgs(files=files)
|
else:
|
||||||
|
stats_args = None
|
||||||
|
return (profile_args, stats_args)
|
||||||
|
|
||||||
async def run(self, interaction: discord.Interaction):
|
async def run(self, interaction: discord.Interaction):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user