diff --git a/subathon/channel.py b/subathon/channel.py new file mode 100644 index 0000000..760977b --- /dev/null +++ b/subathon/channel.py @@ -0,0 +1,191 @@ +from typing import Optional, TypeAlias, TypedDict + +from collections import defaultdict +from datetime import datetime, timedelta +from dataclasses import dataclass + +from meta.sockets import Channel +from utils.lib import utc_now +from modules.profiles.profiles.profiles import ProfilesRegistry + +from . import logger +from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal +from .subathon import ActiveSubathon, SubathonRegistry + + +# ISO formatted timestamp +ISOTimestamp: TypeAlias = str + + +class GoalPayload(TypedDict): + required: float + name: str + + +class ContributionPayload(TypedDict): + user_name: str + user_id: str | None + + amount: float + seconds_added: int + timestamp: ISOTimestamp + + +class ScoreTablePayload(TypedDict): + bit_score: float # points per bit + t1_score: float # points per T1 sub + t2_score: float # points per T2 sub + t3_score: float # points per T3 sub + + score_time: float # seconds added per point + + +class SubathonPayload(TypedDict): + end_at: ISOTimestamp + is_running: bool + score_table: ScoreTablePayload + name: str + + total_contribution: float + goals_met: int + goals_total: int + + last_goal: GoalPayload | None + next_goal: GoalPayload | None + last_contribution: ContributionPayload | None + + +async def prepare_subathon(profiler: ProfilesRegistry, subathon: ActiveSubathon) -> SubathonPayload: + now = utc_now() + + total_score = await subathon.get_score() + goals = await subathon.get_goals() + goals_met = 0 + last_goal = None + next_goal = None + total_goals = len(goals) + for goal in goals: + if goal.required_score >= total_score: + last_goal = goal + goals_met += 1 + else: + next_goal = goal + break + + contribs = await subathon.fetch_contributions().limit(1) + last_contrib: ContributionPayload | None = None + if contribs: + contrib = contribs[0] + if contrib.profileid: + profile = await profiler.get_profile(contrib.profileid) + assert profile is not None + name = profile.nickname + user_id = None + else: + name = 'Anonymous' + user_id = None + last_contrib = ContributionPayload( + user_name=name, + user_id=user_id, + amount=contrib.score, + seconds_added=subathon.get_score_time(contrib.score), + timestamp=contrib.created_at.isoformat() + ) + + score_table = ScoreTablePayload( + bit_score=subathon.subathondata.bit_score, + t1_score=subathon.subathondata.sub1_score, + t2_score=subathon.subathondata.sub2_score, + t3_score=subathon.subathondata.sub3_score, + score_time=subathon.subathondata.score_time, + ) + payload = SubathonPayload( + name=subathon.subathondata.name, + end_at=(await subathon.get_ending()).isoformat(), + is_running=(subathon.running), + score_table=score_table, + total_contribution=await subathon.get_score(), + goals_met=goals_met, + goals_total=total_goals, + last_goal=GoalPayload( + required=last_goal.required_score, + name=last_goal.description, + ) if last_goal is not None else None, + next_goal=GoalPayload( + required=next_goal.required_score, + name=next_goal.description, + ) if next_goal is not None else None, + last_contribution=last_contrib + ) + return payload + + +class TimerChannel(Channel): + name = 'SubTimer' + + def __init__(self, profiler: ProfilesRegistry, subathons: SubathonRegistry, **kwargs): + super().__init__(**kwargs) + + self.profiler: ProfilesRegistry = profiler + self.subathons: SubathonRegistry = subathons + + # Map of communities to webhooks listening for this community + self.communities = defaultdict(set) # Map of communityid -> listening websockets + + async def on_connection(self, websocket, event): + if not (cidstr := event.get('community')): + raise ValueError("Subathon timer connection missing communityid") + elif not cidstr.isdigit(): + raise ValueError("Community id provided is not an integer") + cid = int(cidstr) + community = await self.profiler.get_community(cid) + if community is None: + raise ValueError("Unknown community provided.") + + await super().on_connection(websocket, event) + self.communities[cid].add(websocket) + + subathon = await self.subathons.get_active_subathon(cid) + if subathon: + payload = await prepare_subathon(self.profiler, subathon) + await self.send_subathon_update(cid, payload, websocket) + else: + await self.send_no_subathon(cid, websocket) + + async def del_connection(self, websocket): + for wss in self.communities.values(): + wss.discard(websocket) + await super().del_connection(websocket) + + async def send_subathon_update(self, communityid: int, payload: SubathonPayload, websocket=None): + for ws in (websocket,) if websocket else self.communities[communityid]: + await self.send_event( + { + 'type': "DO", + 'method': "setTimer", + 'args': payload, + }, + websocket=ws + ) + + async def send_subathon_ended(self, communityid: int, payload: SubathonPayload, websocket=None): + for ws in (websocket,) if websocket else self.communities[communityid]: + await self.send_event( + { + 'type': "DO", + 'method': "endTimer", + 'args': payload, + }, + websocket=ws + ) + + async def send_no_subathon(self, communityid: int, websocket=None): + for ws in (websocket,) if websocket else self.communities[communityid]: + await self.send_event( + { + 'type': "DO", + 'method': "noTimer", + 'args': {}, + }, + websocket=ws + ) diff --git a/subathon/component.py b/subathon/component.py index 5069fdf..d95602d 100644 --- a/subathon/component.py +++ b/subathon/component.py @@ -1,7 +1,6 @@ from collections import defaultdict from datetime import datetime, timedelta from typing import Optional -import random import twitchio from twitchio import PartialUser, Scopes, eventsub from twitchio.ext import commands as cmds @@ -13,147 +12,8 @@ from utils.lib import utc_now, strfdelta from . import logger from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal - - -class TimerChannel(Channel): - name = 'SubTimer' - - def __init__(self, cog: 'SubathonComponent', **kwargs): - super().__init__(**kwargs) - self.cog = cog - - # This is horribly inefficient but will be deprecated by the web update - self.communities = defaultdict(set) # Map of communityid -> listening websockets - - async def on_connection(self, websocket, event): - # TODO: Properly this should be communityid - # Which is retrieved via API call to the profiles module for the channel - # This should also be a different error so we can pass it back to the client. - if not event.get('channelid', None): - raise ValueError("Subtimer connection missing channel!") - community = await self.cog.bot.profiles.profiles.get_community_twitch(event['channelid']) - if community is None: - raise ValueError('Requested channel is not registered. Add the bot first.') - - await super().on_connection(websocket, event) - self.communities[community.communityid].add(websocket) - - await self.send_set( - **await self.get_args_for(community.communityid), - websocket=websocket, - ) - - async def del_connection(self, websocket): - for wss in self.communities.values(): - wss.discard(websocket) - await super().del_connection(websocket) - - async def send_updates(self, communityid: int): - args = await self.get_args_for(communityid) - for ws in self.communities[communityid]: - await self.send_set( - **args, - websocket=ws - ) - - async def get_args_for(self, communityid: int): - active = await self.cog.get_active_subathon(communityid) - if active is not None: - ending = utc_now() + timedelta(seconds=await active.get_remaining()) - # TODO: Next goal info and overall goal progress, maybe last contrib - return { - 'end_at': ending, - 'running': active.running - } - else: - return { - 'end_at': utc_now, - 'running': False, - } - - async def send_set(self, end_at, running, websocket=None): - await self.send_event({ - 'type': "DO", - 'method': 'setTimer', - 'args': { - 'end_at': end_at.isoformat(), - 'running': running, - } - }, websocket=websocket) - - -class ActiveSubathon: - def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None): - self.subathondata = subathondata - self.runningdata = runningdata - - @property - def running(self): - return self.runningdata is not None - - @property - def name(self): - return self.subathondata.name or 'Subathon' - - async def check_cap(self): - if not (cap := self.subathondata.timecap): - return False - else: - score = await self.get_score() - time_earned = self.get_score_time(score) - total_time = self.subathondata.initial_time + time_earned - return total_time >= cap - - async def pause(self): - if not self.running: - raise ValueError("This subathon is not running!") - assert self.runningdata is not None - new_duration = self.get_duration() - await self.subathondata.update(duration=new_duration) - await self.runningdata.delete() - self.runningdata = None - - async def resume(self): - if self.running: - raise ValueError("This subathon is already running!") - self.runningdata = await RunningSubathon.create(subathon_id=self.subathondata.subathon_id) - - async def get_score(self) -> float: - rows = await SubathonContribution.fetch_where(subathon_id=self.subathondata.subathon_id) - return sum(row.score for row in rows) - - def get_score_time(self, score: float) -> int: - # Get time contributed by this score - return int(score * self.subathondata.score_time) - - def get_duration(self) -> int: - # Get the duration of this subathon so far - duration = self.subathondata.duration - if self.runningdata: - now = utc_now() - added = int( (now - self.runningdata.last_started).total_seconds() ) - duration += added - return duration - - async def get_remaining(self) -> int: - # Get the remaining time - score = await self.get_score() - time_earned = self.get_score_time(score) - total_time = self.subathondata.initial_time + time_earned - if cap := self.subathondata.timecap: - total_time = min(total_time, cap) - - return total_time - self.get_duration() - - async def add_contribution(self, profileid: int | None, score: float, event_id: int | None) -> SubathonContribution: - return await SubathonContribution.create( - subathon_id=self.subathondata.subathon_id, - profileid=profileid, score=score, event_id=event_id - ) - - async def get_goals(self) -> list[SubathonGoal]: - goals = await SubathonGoal.fetch_where(subathon_id=self.subathondata.subathon_id).order_by('required_score') - return goals +from .subathon import ActiveSubathon, SubathonRegistry +from .channel import SubathonPayload, prepare_subathon, TimerChannel class SubathonComponent(cmds.Component): @@ -163,25 +23,29 @@ class SubathonComponent(cmds.Component): def __init__(self, bot: Bot): self.bot = bot self.data = bot.dbconn.load_registry(SubathonData()) - self.channel = TimerChannel(self) + self.subathons = SubathonRegistry(self.data, bot.profiles.profiles) + self.channel = TimerChannel(self.bot.profiles.profiles, self.subathons) + register_channel('SubTimer', self.channel) # ----- API ----- async def component_load(self): await self.data.init() + await self.subathons.init() await self.bot.version_check(*self.data.VERSION) async def component_teardown(self): pass + async def dispatch_update(self, subathon: ActiveSubathon): + # TODO: Fix confusion of responsibility for preparation + cid = subathon.subathondata.communityid + payload = await prepare_subathon(self.bot.profiles.profiles, subathon) + await self.channel.send_subathon_update(cid, payload) + # ----- Methods ----- - async def get_active_subathon(self, communityid: int) -> ActiveSubathon | None: - rows = await Subathon.fetch_where(communityid=communityid, ended_at=None) - if rows: - subathondata = rows[0] - running = await RunningSubathon.fetch(subathondata.subathon_id) - subba = ActiveSubathon(subathondata, running) - return subba + async def get_active_subathon(self, *args, **kwargs): + return await self.subathons.get_active_subathon(*args, **kwargs) async def goalcheck(self, active: ActiveSubathon, channel: PartialUser): goals = await active.get_goals() @@ -199,7 +63,7 @@ class SubathonComponent(cmds.Component): @cmds.Component.listener() async def event_safe_bits_use(self, payload): event_row, detail_row, bits_payload = payload - if (active := await self.get_active_subathon(event_row['communityid'])) is not None: + if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished(): # In an active subathon pid = event_row['profileid'] uid = event_row['user_id'] @@ -226,7 +90,7 @@ class SubathonComponent(cmds.Component): contrib_str, sender=self.bot.bot_id ) - await self.channel.send_updates(event_row['communityid']) + await self.dispatch_update(active) await self.goalcheck(active, bits_payload.broadcaster) # Check goals @@ -237,7 +101,7 @@ class SubathonComponent(cmds.Component): # Ignore gifted here return - if (active := await self.get_active_subathon(event_row['communityid'])) is not None: + if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished(): data = active.subathondata # In an active subathon pid = event_row['profileid'] @@ -272,7 +136,7 @@ class SubathonComponent(cmds.Component): contrib_str, sender=self.bot.bot_id ) - await self.channel.send_updates(event_row['communityid']) + await self.dispatch_update(active) # Check goals await self.goalcheck(active, sub_payload.broadcaster) @@ -280,7 +144,7 @@ class SubathonComponent(cmds.Component): async def event_safe_subscription_gift(self, payload): event_row, detail_row, gift_payload = payload - if (active := await self.get_active_subathon(event_row['communityid'])) is not None: + if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished(): data = active.subathondata # In an active subathon pid = event_row['profileid'] @@ -314,7 +178,7 @@ class SubathonComponent(cmds.Component): contrib_str, sender=self.bot.bot_id ) - await self.channel.send_updates(event_row['communityid']) + await self.dispatch_update(active) # Check goals await self.goalcheck(active, gift_payload.broadcaster) @@ -322,7 +186,7 @@ class SubathonComponent(cmds.Component): async def event_safe_subscription_message(self, payload): event_row, detail_row, sub_payload = payload - if (active := await self.get_active_subathon(event_row['communityid'])) is not None: + if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished(): data = active.subathondata # In an active subathon pid = event_row['profileid'] @@ -357,7 +221,7 @@ class SubathonComponent(cmds.Component): contrib_str, sender=self.bot.bot_id ) - await self.channel.send_updates(event_row['communityid']) + await self.dispatch_update(active) # Check goals await self.goalcheck(active, sub_payload.broadcaster) @@ -369,11 +233,12 @@ class SubathonComponent(cmds.Component): if (active := await self.get_active_subathon(cid)) is not None: if active.running: await active.pause() - await payload.broadcaster.send_message( - "Paused the subathon timer because the stream went offline!", - sender=self.bot.bot_id - ) - await self.channel.send_updates(cid) + if not await active.check_finished(): + await payload.broadcaster.send_message( + "Paused the subathon timer because the stream went offline!", + sender=self.bot.bot_id + ) + await self.dispatch_update(active) # ----- Commands ----- @@ -388,15 +253,21 @@ class SubathonComponent(cmds.Component): donegoals = len([goal for goal in goals if score >= goal.required_score]) goalstr = f"{donegoals}/{total_goals} goals achieved" + dursecs = await active.get_duration() + duration = strfdelta(timedelta(seconds=dursecs)) + secs = await active.get_remaining() - remaining = strfdelta(timedelta(seconds=secs)) + if secs >= 0: + remaining = strfdelta(timedelta(seconds=secs)) - secs = active.get_duration() - duration = strfdelta(timedelta(seconds=secs)) + text = ( + f"{active.name} running for {duration}! {score} points recieved, {goalstr}, and {remaining} left on the timer" + ) + else: + text = ( + f"{active.name} completed after {duration} with a total of {score} points, and {goalstr}!" + ) - text = ( - f"{active.name} running for {duration}! {score} points recieved, {goalstr}, and {remaining} left on the timer" - ) await ctx.reply(text) else: await ctx.reply("No active subathon running!") @@ -415,7 +286,7 @@ class SubathonComponent(cmds.Component): sub3_points = points per T3 sub bit_points = points per bit timepoints = seconds to be added to the timer per point - timecap (optional) = number of seconds to cap the timer at. + timecap (optional) = number of hours to cap the timer at """ community = await self.bot.profiles.fetch_community(ctx.broadcaster) cid = community.communityid @@ -423,8 +294,9 @@ class SubathonComponent(cmds.Component): await ctx.reply("There is already an active subathon running! Use !subathon stop to stop it!") return initial_time = initial_hours * 60 * 60 + timecap_seconds = timecap * 60 * 60 if timecap else None - active = await Subathon.create( + subdata = await Subathon.create( communityid=cid, name=name, initial_time=initial_time, @@ -433,12 +305,13 @@ class SubathonComponent(cmds.Component): sub3_score=sub3, bit_score=bit, score_time=timescore, - timecap=timecap + timecap=timecap_seconds ) base_timer_url = self.bot.config.subathon['timer_url'] - timer_link = f"{base_timer_url}?channelid={ctx.channel.id}" + timer_link = f"{base_timer_url}?community={cid}" await ctx.reply(f"Setup your {name}! Use !subathon resume to get the timer running. Your timer link: {timer_link}") - await self.channel.send_updates(cid) + active = ActiveSubathon(subdata, None) + await self.dispatch_update(active) # subathon stop @group_subathon.command(name='stop') @@ -449,14 +322,14 @@ class SubathonComponent(cmds.Component): if (active := await self.get_active_subathon(cid)) is not None: if active.running: await active.pause() - await self.channel.send_updates(cid) + await self.dispatch_update(active) await active.subathondata.update(ended_at=utc_now()) total = await active.get_score() - dursecs = active.get_duration() + dursecs = await active.get_duration() dur = strfdelta(timedelta(seconds=dursecs)) await ctx.reply( - f"{active.name} complete after {dur} with a total of {total} points, congratulations!" + f"{active.name} ended after {dur} with a total of {total} points, congratulations!" ) else: await ctx.reply("No active subathon to stop.") @@ -471,7 +344,7 @@ class SubathonComponent(cmds.Component): if active.running: await active.pause() await ctx.reply(f"{active.name} timer paused!") - await self.channel.send_updates(cid) + await self.dispatch_update(active) else: await ctx.reply(f"{active.name} timer already paused!") else: @@ -484,12 +357,14 @@ class SubathonComponent(cmds.Component): community = await self.bot.profiles.fetch_community(ctx.broadcaster) cid = community.communityid if (active := await self.get_active_subathon(cid)) is not None: - if not active.running: + if active.running: + await ctx.reply(f"{active.name} timer already running!") + elif await active.check_finished(): + await ctx.reply(f"{active.name} has already finished!") + else: await active.resume() await ctx.reply(f"{active.name} timer resumed!") - await self.channel.send_updates(cid) - else: - await ctx.reply(f"{active.name} timer already running!") + await self.dispatch_update(active) else: await ctx.reply("No active subathon to resume") @@ -522,7 +397,7 @@ class SubathonComponent(cmds.Component): name = None pid = None await active.add_contribution(pid, amount, None) - await self.channel.send_updates(cid) + await self.dispatch_update(active) # Build message if amount > 0: @@ -538,6 +413,43 @@ class SubathonComponent(cmds.Component): else: await ctx.reply("No active subathon to adjust") + # subathon config + @group_subathon.command(name='config', aliases=('option',)) + @cmds.is_moderator() + async def cmd_subathon_config(self, ctx: Context, option: str, *, value: Optional[str] = None): + community = await self.bot.profiles.fetch_community(ctx.broadcaster) + cid = community.communityid + if (active := await self.get_active_subathon(cid)) is not None: + if option.lower() == 'cap': + if value: + # Set the timer cap + if not value.isdigit(): + await ctx.reply("Timer cap must be an integer number of hours!") + else: + await active.subathondata.update(timecap=int(value * 60 * 60)) + await self.dispatch_update(active) + await ctx.reply( + f"The timer cap has been set to {value} hours." + ) + else: + # Display the timer cap + cap = active.subathondata.timecap + if cap: + hours = cap / 3600 + await ctx.reply(f"The timer cap is currently {hours} hours") + elif option.lower() == 'name': + if value: + await active.subathondata.update(name=value) + await self.dispatch_update(active) + await ctx.reply(f"Updated the subathon name to \"{value}\"") + else: + name = active.subathondata.name + await ctx.reply(f"This subathon is called \"{name}\"") + else: + await ctx.reply( + f"Unknown option {option}! Configurable options: 'name', 'cap'" + ) + @group_subathon.command(name='leaderboard', aliases=('top', 'lb',)) async def cmd_subathon_lb(self, ctx: Context): """ @@ -615,6 +527,29 @@ class SubathonComponent(cmds.Component): else: await ctx.reply("No active subathon running!") + @group_goals.command(name='remaining', aliases=('left',)) + async def cmd_goals_remaining(self, ctx: cmds.Context): + community = await self.bot.profiles.fetch_community(ctx.broadcaster) + cid = community.communityid + if (active := await self.get_active_subathon(cid)) is not None: + score = await active.get_score() + goals = await active.get_goals() + goalstrs = [] + for i, goal in enumerate(goals, start=1): + if goal.required_score > score: + line = f"{goal.required_score} points: {goal.description}" + goalstrs.append(line) + + if goalstrs + text = ', '.join(goalstrs) + await ctx.reply(f"{active.name} Goals Remaining -- {text}") + elif goals: + await ctx.reply(f"All goals completed, congratulations!") + else: + await ctx.reply("No goals have been configured!") + else: + await ctx.reply("No active subathon running!") + @group_goals.command(name='add') @cmds.is_moderator() async def cmd_add(self, ctx: cmds.Context, required: int, *, description: str): @@ -630,3 +565,23 @@ class SubathonComponent(cmds.Component): else: await ctx.reply("No active subathon to add goal to!") + # remove + @group_goals.command(name='remove', aliases=['del', 'delete', 'rm']) + @cmds.is_moderator() + async def cmd_goals_remove(self, ctx: cmds.Context, required: int): + """ + Description: + Remove any goal(s) set at the given score. + """ + community = await self.bot.profiles.fetch_community(ctx.broadcaster) + cid = community.communityid + if (active := await self.get_active_subathon(cid)) is not None: + results = await SubathonGoal.table.delete_where( + subathon_id=active.subathondata.subathon_id, + required_score=required, + ) + if results: + await ctx.reply(f"Score {required} goal(s) removed!") + else: + await ctx.reply(f"No goal set at {required} score to remove.") + diff --git a/subathon/subathon.py b/subathon/subathon.py new file mode 100644 index 0000000..d4386ec --- /dev/null +++ b/subathon/subathon.py @@ -0,0 +1,148 @@ +from asyncio import Event +from datetime import datetime, timedelta +from utils.lib import utc_now, strfdelta +from modules.profiles.profiles.profiles import ProfilesRegistry + +from . import logger +from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal + +class ActiveSubathon: + def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None): + self.subathondata = subathondata + self.runningdata = runningdata + + @property + def running(self): + return self.runningdata is not None + + @property + def name(self): + return self.subathondata.name or 'Subathon' + + async def check_cap(self): + if not (cap := self.subathondata.timecap): + return False + else: + score = await self.get_score() + time_earned = self.get_score_time(score) + total_time = self.subathondata.initial_time + time_earned + return total_time >= cap + + async def check_finished(self): + """ + Return True if the subathon duration has exceeded its earned time. + """ + return (await self.get_duration() <= 0) + + async def pause(self): + """ + Pause the active subathon. + If the subathon is in 'overtime', i.e. it has already finished, + then the total time is saved as the earned time not including the overtime. + """ + if not self.running: + raise ValueError("This subathon is not running!") + assert self.runningdata is not None + + new_duration = await self.get_duration() + + await self.subathondata.update(duration=new_duration) + await self.runningdata.delete() + self.runningdata = None + + async def resume(self): + if self.running: + raise ValueError("This subathon is already running!") + self.runningdata = await RunningSubathon.create(subathon_id=self.subathondata.subathon_id) + + async def get_score(self) -> float: + rows = await SubathonContribution.fetch_where(subathon_id=self.subathondata.subathon_id) + return sum(row.score for row in rows) + + def get_score_time(self, score: float) -> int: + # Get time contributed by this score + return int(score * self.subathondata.score_time) + + async def get_duration(self) -> int: + """ + Number of seconds that this subathon has run for. + Includes current running session if it exists. + + If the subathon is already finished (in overtime) + returns the earned time instead of the true duration. + """ + duration = self.subathondata.duration + # Add running duration if required + if self.runningdata: + now = utc_now() + added = int( (now - self.runningdata.last_started).total_seconds() ) + duration += added + earned = await self.get_earned() + + return min(duration, earned) + + async def get_earned(self) -> int: + """ + Number of seconds earned in the subathon so far. + Includes initial time. + Takes into account the cap. + """ + score = await self.get_score() + time_earned = self.get_score_time(score) + total_time = self.subathondata.initial_time + time_earned + if cap := self.subathondata.timecap: + total_time = min(total_time, cap) + return total_time + + async def get_remaining(self) -> int: + """ + Number of seconds remaining on the subathon timer. + Will be 0 if finished. + """ + total_time = await self.get_earned() + return total_time - await self.get_duration() + + async def get_ending(self): + """ + Ending time of the subathon. + May be in the past if the subathon has already ended. + """ + now = utc_now() + remaining = await self.get_remaining() + return now + timedelta(seconds=remaining) + + async def add_contribution(self, profileid: int | None, score: float, event_id: int | None) -> SubathonContribution: + return await SubathonContribution.create( + subathon_id=self.subathondata.subathon_id, + profileid=profileid, score=score, event_id=event_id + ) + + def fetch_contributions(self, **kwargs): + query = SubathonContribution.fetch_where( + subathon_id=self.subathondata.subathon_id, + **kwargs + ).order_by('created_at') + return query + + async def get_goals(self) -> list[SubathonGoal]: + goals = await SubathonGoal.fetch_where(subathon_id=self.subathondata.subathon_id).order_by('required_score') + return goals + + +class SubathonRegistry: + def __init__(self, data: SubathonData, profiler: ProfilesRegistry): + self.data = data + self.profiler = profiler + + async def init(self): + await self.data.init() + + async def get_active_subathon(self, communityid: int) -> ActiveSubathon | None: + rows = await Subathon.fetch_where(communityid=communityid, ended_at=None) + if rows: + subathondata = rows[0] + running = await RunningSubathon.fetch(subathondata.subathon_id) + subba = ActiveSubathon(subathondata, running) + return subba + +