rewrite: Profile, Stats, Leaderboard.

This commit is contained in:
2023-05-14 12:31:43 +03:00
parent 16bc05d39b
commit 7f79009ac7
13 changed files with 1447 additions and 51 deletions

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