(sessions): Add daily_study_cap system.
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -174,3 +174,5 @@ AS $$
|
|||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE PLPGSQL;
|
$$ LANGUAGE PLPGSQL;
|
||||||
|
|
||||||
|
ALTER TABLE guild_config ADD COLUMN daily_study_cap INTEGER;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user