From 734436e2a61e55e2fb00bf8452d67f9e35d9b17f Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 2 Dec 2021 11:48:04 +0200 Subject: [PATCH] (sessions): Add `daily_study_cap` system. --- bot/core/data.py | 2 +- bot/core/lion.py | 36 +++- bot/modules/study/tracking/session_tracker.py | 177 ++++++++++++++++-- bot/modules/study/tracking/settings.py | 14 +- data/migration/v5-v6/migration.sql | 2 + data/schema.sql | 2 +- 6 files changed, 207 insertions(+), 26 deletions(-) diff --git a/bot/core/data.py b/bot/core/data.py index 8ae74874..cd3f850b 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -25,7 +25,7 @@ guild_config = RowTable( ('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'alert_channel', 'min_workout_length', 'workout_reward', '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'), 'guildid', cache=TTLCache(1000, ttl=60*5) diff --git a/bot/core/lion.py b/bot/core/lion.py index d9eb7ea1..15bed9e0 100644 --- a/bot/core/lion.py +++ b/bot/core/lion.py @@ -1,5 +1,5 @@ import pytz -import datetime +from datetime import datetime, timedelta from meta import client from data import tables as tb @@ -79,10 +79,17 @@ class Lion: @property def settings(self): """ - The UserSettings object for this user. + The UserSettings interface for this member. """ return UserSettings(self.userid) + @property + def guild_settings(self): + """ + The GuildSettings interface for this member. + """ + return GuildSettings(self.guildid) + @property def time(self): """ @@ -118,10 +125,15 @@ class Lion: def day_start(self): """ 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) + @property + def remaining_in_day(self): + return ((self.day_start + timedelta(days=1)) - datetime.now(self.timezone)).total_seconds() + @property 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) + @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): """ Localise the provided naive UTC datetime into the user's timezone. diff --git a/bot/modules/study/tracking/session_tracker.py b/bot/modules/study/tracking/session_tracker.py index fb9b9341..146f1195 100644 --- a/bot/modules/study/tracking/session_tracker.py +++ b/bot/modules/study/tracking/session_tracker.py @@ -2,29 +2,42 @@ import asyncio import discord import logging import traceback +from typing import Dict from collections import defaultdict from utils.lib import utc_now from data import tables from core import Lion +from meta import client from ..module import module 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: """ - 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. """ - # TODO: Slots - sessions = defaultdict(dict) + __slots__ = ( + '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): self.guildid = guildid self.userid = userid - self.key = (guildid, userid) + + self._expiry_task: asyncio.Task = None @classmethod def get(cls, guildid, userid): @@ -45,7 +58,22 @@ class Session: if userid in cls.sessions[guildid]: 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 if state.channel.id in tables.rented.row_cache: @@ -67,23 +95,104 @@ class Session: hourly_coins=hourly_reward.get(guildid).value, hourly_live_coins=hourly_live_bonus.get(guildid).value ) - session = cls(guildid, userid) - cls.sessions[guildid][userid] = session - return session + session = cls(guildid, userid).activate() + client.log( + "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 def data(self): 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): """ Close the study session. """ - self.sessions[self.guildid].pop(self.userid, None) # Note that save_live_status doesn't need to be called here # The database saving procedure will account for the values. 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): """ Update the saved live status of the member. @@ -128,11 +237,43 @@ async def session_voice_tracker(client, member, before, after): else: # Member changed channel # 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 (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 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: blacklist = client.objects['blacklisted_users'] guild_blacklist = client.objects['ignored_members'][guild.id] @@ -144,7 +285,15 @@ async def session_voice_tracker(client, member, before, after): ) if start_session: # 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): @@ -190,7 +339,7 @@ async def _init_session_tracker(client): context="SESSION_INIT", level=logging.DEBUG ) - Session.sessions[row.guildid][row.userid] = session + session.activate() session.save_live_status(voice) resumed += 1 else: diff --git a/bot/modules/study/tracking/settings.py b/bot/modules/study/tracking/settings.py index dc93cc00..d7d583ab 100644 --- a/bot/modules/study/tracking/settings.py +++ b/bot/modules/study/tracking/settings.py @@ -1,5 +1,3 @@ -from collections import defaultdict - import settings from settings import GuildSettings 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 active_guildids: + cache = {guildid: [] for guildid in active_guildids} rows = cls._table_interface.select_where( guildid=active_guildids ) - cache = defaultdict(list) for row in rows: cache[row['guildid']].append(row['channelid']) cls._cache.update(cache) @@ -114,11 +112,13 @@ class hourly_live_bonus(settings.Integer, settings.GuildSetting): @GuildSettings.attach_setting -class max_daily_study(settings.Duration, settings.GuildSetting): +class daily_study_cap(settings.Duration, settings.GuildSetting): category = "Study Tracking" - attr_name = "max_daily_study" - _data_column = "max_daily_study" + attr_name = "daily_study_cap" + _data_column = "daily_study_cap" - display_name = "max_daily_study" + display_name = "daily_study_cap" desc = "Maximum amount of study time ..." + + _default = 16 * 60 * 60 diff --git a/data/migration/v5-v6/migration.sql b/data/migration/v5-v6/migration.sql index 9e404e13..3e64007a 100644 --- a/data/migration/v5-v6/migration.sql +++ b/data/migration/v5-v6/migration.sql @@ -174,3 +174,5 @@ AS $$ ); END; $$ LANGUAGE PLPGSQL; + +ALTER TABLE guild_config ADD COLUMN daily_study_cap INTEGER; diff --git a/data/schema.sql b/data/schema.sql index 51ba3ecd..9f821f01 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -78,7 +78,7 @@ CREATE TABLE guild_config( returning_message TEXT, starting_funds INTEGER, persist_roles BOOLEAN, - max_daily_study INTEGER + daily_study_cap INTEGER ); CREATE TABLE ignored_members(