(sessions): Add daily_study_cap system.

This commit is contained in:
2021-12-02 11:48:04 +02:00
parent ac71c4da9b
commit 734436e2a6
6 changed files with 207 additions and 26 deletions

View File

@@ -25,7 +25,7 @@ guild_config = RowTable(
('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'alert_channel', ('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'alert_channel',
'min_workout_length', 'workout_reward', 'min_workout_length', 'workout_reward',
'max_tasks', 'task_reward', 'task_reward_limit', 'max_tasks', 'task_reward', 'task_reward_limit',
'study_hourly_reward', 'study_hourly_live_bonus', 'study_hourly_reward', 'study_hourly_live_bonus', 'daily_study_cap',
'study_ban_role', 'max_study_bans'), 'study_ban_role', 'max_study_bans'),
'guildid', 'guildid',
cache=TTLCache(1000, ttl=60*5) cache=TTLCache(1000, ttl=60*5)

View File

@@ -1,5 +1,5 @@
import pytz import pytz
import datetime from datetime import datetime, timedelta
from meta import client from meta import client
from data import tables as tb from data import tables as tb
@@ -79,10 +79,17 @@ class Lion:
@property @property
def settings(self): def settings(self):
""" """
The UserSettings object for this user. The UserSettings interface for this member.
""" """
return UserSettings(self.userid) return UserSettings(self.userid)
@property
def guild_settings(self):
"""
The GuildSettings interface for this member.
"""
return GuildSettings(self.guildid)
@property @property
def time(self): def time(self):
""" """
@@ -118,10 +125,15 @@ class Lion:
def day_start(self): def day_start(self):
""" """
A timezone aware datetime representing the start of the user's day (in their configured timezone). A timezone aware datetime representing the start of the user's day (in their configured timezone).
NOTE: This might not be accurate over DST boundaries.
""" """
now = datetime.datetime.now(tz=self.timezone) now = datetime.now(tz=self.timezone)
return now.replace(hour=0, minute=0, second=0, microsecond=0) return now.replace(hour=0, minute=0, second=0, microsecond=0)
@property
def remaining_in_day(self):
return ((self.day_start + timedelta(days=1)) - datetime.now(self.timezone)).total_seconds()
@property @property
def studied_today(self): def studied_today(self):
""" """
@@ -130,6 +142,24 @@ class Lion:
""" """
return tb.session_history.queries.study_time_since(self.guildid, self.userid, self.day_start) return tb.session_history.queries.study_time_since(self.guildid, self.userid, self.day_start)
@property
def remaining_study_today(self):
"""
Maximum remaining time (in seconds) this member can study today.
May not account for DST boundaries and leap seconds.
"""
studied_today = self.studied_today
study_cap = self.guild_settings.daily_study_cap.value
remaining_in_day = self.remaining_in_day
if remaining_in_day >= (study_cap - studied_today):
remaining = study_cap - studied_today
else:
remaining = remaining_in_day + study_cap
return remaining
def localize(self, naive_utc_dt): def localize(self, naive_utc_dt):
""" """
Localise the provided naive UTC datetime into the user's timezone. Localise the provided naive UTC datetime into the user's timezone.

View File

@@ -2,29 +2,42 @@ import asyncio
import discord import discord
import logging import logging
import traceback import traceback
from typing import Dict
from collections import defaultdict from collections import defaultdict
from utils.lib import utc_now from utils.lib import utc_now
from data import tables from data import tables
from core import Lion from core import Lion
from meta import client
from ..module import module from ..module import module
from .data import current_sessions, SessionChannelType from .data import current_sessions, SessionChannelType
from .settings import untracked_channels, hourly_reward, hourly_live_bonus, max_daily_study from .settings import untracked_channels, hourly_reward, hourly_live_bonus
class Session: class Session:
""" """
A `Session` is a guild member that is currently studying (i.e. that is in a tracked voice channel). A `Session` describes an ongoing study session by a single guild member.
A member is counted as studying when they are in a tracked voice channel.
This class acts as an opaque interface to the corresponding `sessions` data row. This class acts as an opaque interface to the corresponding `sessions` data row.
""" """
# TODO: Slots __slots__ = (
sessions = defaultdict(dict) 'guildid',
'userid',
'_expiry_task'
)
# Global cache of ongoing sessions
sessions: Dict[int, Dict[int, 'Session']] = defaultdict(dict)
# Global cache of members pending session start (waiting for daily cap reset)
members_pending: Dict[int, Dict[int, asyncio.Task]] = defaultdict(dict)
def __init__(self, guildid, userid): def __init__(self, guildid, userid):
self.guildid = guildid self.guildid = guildid
self.userid = userid self.userid = userid
self.key = (guildid, userid)
self._expiry_task: asyncio.Task = None
@classmethod @classmethod
def get(cls, guildid, userid): def get(cls, guildid, userid):
@@ -45,7 +58,22 @@ class Session:
if userid in cls.sessions[guildid]: if userid in cls.sessions[guildid]:
raise ValueError("A session for this member already exists!") raise ValueError("A session for this member already exists!")
# TODO: Handle daily study cap
# If the user is study capped, schedule the session start for the next day
if (lion := Lion.fetch(guildid, userid)).remaining_study_today <= 0:
if pending := cls.members_pending[guildid].pop(userid, None):
pending.cancel()
task = asyncio.create_task(cls._delayed_start(guildid, userid, member, state))
cls.members_pending[guildid][userid] = task
client.log(
"Member (uid:{}) in (gid:{}) is study capped, "
"delaying session start for {} seconds until start of next day.".format(
userid, guildid, lion.remaining_in_day
),
context="SESSION_TRACKER",
level=logging.DEBUG
)
return
# TODO: More reliable channel type determination # TODO: More reliable channel type determination
if state.channel.id in tables.rented.row_cache: if state.channel.id in tables.rented.row_cache:
@@ -67,23 +95,104 @@ class Session:
hourly_coins=hourly_reward.get(guildid).value, hourly_coins=hourly_reward.get(guildid).value,
hourly_live_coins=hourly_live_bonus.get(guildid).value hourly_live_coins=hourly_live_bonus.get(guildid).value
) )
session = cls(guildid, userid) session = cls(guildid, userid).activate()
cls.sessions[guildid][userid] = session client.log(
return session "Started session: {}".format(session.data),
context="SESSION_TRACKER",
level=logging.DEBUG,
)
@classmethod
async def _delayed_start(cls, guildid, userid, *args):
delay = Lion.fetch(guildid, userid).remaining_in_day
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
pass
else:
cls.start(*args)
@property
def key(self):
"""
RowTable Session identification key.
"""
return (self.guildid, self.userid)
@property
def lion(self):
"""
The Lion member object associated with this member.
"""
return Lion.fetch(self.guildid, self.userid)
@property @property
def data(self): def data(self):
return current_sessions.fetch(self.key) return current_sessions.fetch(self.key)
def activate(self):
"""
Activate the study session.
This adds the session to the studying members cache,
and schedules the session expiry, based on the daily study cap.
"""
# Add to the active cache
self.sessions[self.guildid][self.userid] = self
# Schedule the session expiry
self.schedule_expiry()
# Return self for easy chaining
return self
def schedule_expiry(self):
"""
Schedule session termination when the user reaches the maximum daily study time.
"""
asyncio.create_task(self._schedule_expiry())
async def _schedule_expiry(self):
# Cancel any existing expiry
if self._expiry_task and not self._expiry_task.done():
self._expiry_task.cancel()
# Wait for the maximum session length
try:
self._expiry_task = await asyncio.sleep(self.lion.remaining_study_today)
except asyncio.CancelledError:
pass
else:
if self.lion.remaining_study_today <= 0:
# End the session
# Note that the user will not automatically start a new session when the day starts
# TODO: Notify user? Disconnect them?
client.log(
"Session for (uid:{}) in (gid:{}) reached daily guild study cap.\n{}".format(
self.userid, self.guildid, self.data
),
context="SESSION_TRACKER"
)
self.finish()
else:
# It's possible the expiry time was pushed forwards while waiting
# If so, reschedule
self.schedule_expiry()
def finish(self): def finish(self):
""" """
Close the study session. Close the study session.
""" """
self.sessions[self.guildid].pop(self.userid, None)
# Note that save_live_status doesn't need to be called here # Note that save_live_status doesn't need to be called here
# The database saving procedure will account for the values. # The database saving procedure will account for the values.
current_sessions.queries.close_study_session(*self.key) current_sessions.queries.close_study_session(*self.key)
# Remove session from active cache
self.sessions[self.guildid].pop(self.userid, None)
# Cancel any existing expiry task
if self._expiry_task and not self._expiry_task.done():
self._expiry_task.cancel()
def save_live_status(self, state: discord.VoiceState): def save_live_status(self, state: discord.VoiceState):
""" """
Update the saved live status of the member. Update the saved live status of the member.
@@ -128,11 +237,43 @@ async def session_voice_tracker(client, member, before, after):
else: else:
# Member changed channel # Member changed channel
# End the current session and start a new one, if applicable # End the current session and start a new one, if applicable
# TODO: Max daily study session tasks
# TODO: Error if before is None but we have a current session
if session: if session:
if (scid := session.data.channelid) and (not before.channel or scid != before.channel.id):
client.log(
"The previous voice state for "
"member {member.name} (uid:{member.id}) in {guild.name} (gid:{guild.id}) "
"does not match their current study session!\n"
"Session channel is (cid:{scid}), but the previous channel is {previous}.".format(
member=member,
guild=member.guild,
scid=scid,
previous="{0.name} (cid:{0.id})".format(before.channel) if before.channel else "None"
),
context="SESSION_TRACKER",
level=logging.ERROR
)
client.log(
"Ending study session for {member.name} (uid:{member.id}) "
"in {member.guild.id} (gid:{member.guild.id}) since they left the voice channel.\n{session}".format(
member=member,
session=session.data
),
context="SESSION_TRACKER",
post=False
)
# End the current session # End the current session
session.finish() session.finish()
elif pending := Session.members_pending[guild.id].pop(member.id, None):
client.log(
"Cancelling pending study session for {member.name} (uid:{member.id}) "
"in {member.guild.name} (gid:{member.guild.id}) since they left the voice channel.".format(
member=member
),
context="SESSION_TRACKER",
post=False
)
pending.cancel()
if after.channel: if after.channel:
blacklist = client.objects['blacklisted_users'] blacklist = client.objects['blacklisted_users']
guild_blacklist = client.objects['ignored_members'][guild.id] guild_blacklist = client.objects['ignored_members'][guild.id]
@@ -144,7 +285,15 @@ async def session_voice_tracker(client, member, before, after):
) )
if start_session: if start_session:
# Start a new session for the member # Start a new session for the member
Session.start(member, after) client.log(
"Starting a new voice channel study session for {member.name} (uid:{member.id}) "
"in {member.guild.name} (gid:{member.guild.id}).".format(
member=member,
),
context="SESSION_TRACKER",
post=False
)
session = Session.start(member, after)
async def _init_session_tracker(client): async def _init_session_tracker(client):
@@ -190,7 +339,7 @@ async def _init_session_tracker(client):
context="SESSION_INIT", context="SESSION_INIT",
level=logging.DEBUG level=logging.DEBUG
) )
Session.sessions[row.guildid][row.userid] = session session.activate()
session.save_live_status(voice) session.save_live_status(voice)
resumed += 1 resumed += 1
else: else:

View File

@@ -1,5 +1,3 @@
from collections import defaultdict
import settings import settings
from settings import GuildSettings from settings import GuildSettings
from wards import guild_admin from wards import guild_admin
@@ -52,10 +50,10 @@ class untracked_channels(settings.ChannelList, settings.ListData, settings.Setti
if any(channel.members for channel in guild.voice_channels) if any(channel.members for channel in guild.voice_channels)
] ]
if active_guildids: if active_guildids:
cache = {guildid: [] for guildid in active_guildids}
rows = cls._table_interface.select_where( rows = cls._table_interface.select_where(
guildid=active_guildids guildid=active_guildids
) )
cache = defaultdict(list)
for row in rows: for row in rows:
cache[row['guildid']].append(row['channelid']) cache[row['guildid']].append(row['channelid'])
cls._cache.update(cache) cls._cache.update(cache)
@@ -114,11 +112,13 @@ class hourly_live_bonus(settings.Integer, settings.GuildSetting):
@GuildSettings.attach_setting @GuildSettings.attach_setting
class max_daily_study(settings.Duration, settings.GuildSetting): class daily_study_cap(settings.Duration, settings.GuildSetting):
category = "Study Tracking" category = "Study Tracking"
attr_name = "max_daily_study" attr_name = "daily_study_cap"
_data_column = "max_daily_study" _data_column = "daily_study_cap"
display_name = "max_daily_study" display_name = "daily_study_cap"
desc = "Maximum amount of study time ..." desc = "Maximum amount of study time ..."
_default = 16 * 60 * 60

View File

@@ -174,3 +174,5 @@ AS $$
); );
END; END;
$$ LANGUAGE PLPGSQL; $$ LANGUAGE PLPGSQL;
ALTER TABLE guild_config ADD COLUMN daily_study_cap INTEGER;

View File

@@ -78,7 +78,7 @@ CREATE TABLE guild_config(
returning_message TEXT, returning_message TEXT,
starting_funds INTEGER, starting_funds INTEGER,
persist_roles BOOLEAN, persist_roles BOOLEAN,
max_daily_study INTEGER daily_study_cap INTEGER
); );
CREATE TABLE ignored_members( CREATE TABLE ignored_members(