From aa87fd6f9f82865d9824bafaeb851a0be0075c10 Mon Sep 17 00:00:00 2001 From: Interitio Date: Thu, 30 Oct 2025 21:13:17 +1000 Subject: [PATCH] feat: Add data to channel payload. Add entire goal list to payload. Add contributions leaderboard to payload. Add raw sub point total to payload. Add raw bits total to payload. Add subpoint and bits total computations to ActiveSubathon. --- subathon/channel.py | 148 +++++++++++++++++++++++++++++++------------ subathon/subathon.py | 123 +++++++++++++++++++++++++++++++---- 2 files changed, 216 insertions(+), 55 deletions(-) diff --git a/subathon/channel.py b/subathon/channel.py index 7ab6482..d6ebe0a 100644 --- a/subathon/channel.py +++ b/subathon/channel.py @@ -4,12 +4,19 @@ from collections import defaultdict from datetime import datetime, timedelta from dataclasses import dataclass +from data.queries import JOINTYPE, ORDER 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 .data import ( + SubathonData, + Subathon, + RunningSubathon, + SubathonContribution, + SubathonGoal, +) from .subathon import ActiveSubathon, SubathonRegistry @@ -31,6 +38,12 @@ class ContributionPayload(TypedDict): timestamp: ISOTimestamp +class LeaderboardItemPayload(TypedDict): + user_name: str + user_id: str | None + amount: float + + class ScoreTablePayload(TypedDict): bit_score: float # points per bit t1_score: float # points per T1 sub @@ -42,38 +55,52 @@ class ScoreTablePayload(TypedDict): class SubathonPayload(TypedDict): end_at: ISOTimestamp - is_running: bool + is_running: bool score_table: ScoreTablePayload name: str total_contribution: float + total_subpoints: int + total_bits: int + goals_met: int goals_total: int - last_goal: GoalPayload | None next_goal: GoalPayload | None + goals: list[GoalPayload] + last_contribution: ContributionPayload | None + leaderboard: list[LeaderboardItemPayload] -async def prepare_subathon(profiler: ProfilesRegistry, subathon: ActiveSubathon) -> SubathonPayload: - now = utc_now() - +async def prepare_subathon( + profiler: ProfilesRegistry, subathon: ActiveSubathon +) -> SubathonPayload: total_score = await subathon.get_score() + + # Build the goal data goals = await subathon.get_goals() goals_met = 0 - last_goal = None - next_goal = None + last_goalp = None + next_goalp = None + goalps = [] total_goals = len(goals) for goal in goals: + goalp = GoalPayload( + required=float(goal.required_score), + name=goal.description, + ) + goalps.append(goalp) if goal.required_score >= total_score: - last_goal = goal + last_goalp = goalp goals_met += 1 else: - next_goal = goal + next_goalp = goalp break + # Build the last contribution information contribs = await subathon.fetch_contributions().limit(1) - last_contrib: ContributionPayload | None = None + last_contrib: ContributionPayload | None = None if contribs: contrib = contribs[0] if contrib.profileid: @@ -82,16 +109,17 @@ async def prepare_subathon(profiler: ProfilesRegistry, subathon: ActiveSubathon) name = profile.nickname user_id = None else: - name = 'Anonymous' + name = "Anonymous" user_id = None last_contrib = ContributionPayload( user_name=name, user_id=user_id, amount=float(contrib.score), seconds_added=subathon.get_score_time(contrib.score), - timestamp=contrib.created_at.isoformat() + timestamp=contrib.created_at.isoformat(), ) + # Build the score table score_table = ScoreTablePayload( bit_score=float(subathon.subathondata.bit_score), t1_score=float(subathon.subathondata.sub1_score), @@ -99,41 +127,75 @@ async def prepare_subathon(profiler: ProfilesRegistry, subathon: ActiveSubathon) t3_score=float(subathon.subathondata.sub3_score), score_time=int(subathon.subathondata.score_time), ) + + # Build the contribution leaderboard + query = SubathonContribution.table.select_where( + subathon_id=subathon.subathondata.subathon_id + ) + query.join("user_profiles", using=("profileid",), join_type=JOINTYPE.LEFT) + query.select("subathon_id", "profileid", total="SUM(score)") + query.order_by("total", direction=ORDER.DESC) + query.group_by("subathon_id", "profileid") + query.with_no_adapter() + results = await query + + leaderboard = [] + for row in results: + if pid := row["profileid"]: + profile = await profiler.get_profile(pid) + name = (profile.nickname if profile else None) or "Unknown" + else: + name = "Anonymous" + score = row["total"] + item = LeaderboardItemPayload( + user_name=name, + user_id=pid, + amount=float(score), + ) + leaderboard.append(item) + + # Build the raw totals + subpoints = await subathon.get_total_subs() + bits = await subathon.get_total_bits() + + # Finally, put together the payload payload = SubathonPayload( name=subathon.subathondata.name, end_at=(await subathon.get_ending()).isoformat(), is_running=(subathon.running), score_table=score_table, total_contribution=float(await subathon.get_score()), + total_subpoints=subpoints, + total_bits=bits, goals_met=goals_met, goals_total=total_goals, - last_goal=GoalPayload( - required=float(last_goal.required_score), - name=last_goal.description, - ) if last_goal is not None else None, - next_goal=GoalPayload( - required=float(next_goal.required_score), - name=next_goal.description, - ) if next_goal is not None else None, - last_contribution=last_contrib + last_goal=last_goalp, + next_goal=next_goalp, + goals=goalps, + last_contribution=last_contrib, + leaderboard=leaderboard, ) return payload class TimerChannel(Channel): - name = 'SubTimer' + name = "SubTimer" - def __init__(self, profiler: ProfilesRegistry, subathons: SubathonRegistry, **kwargs): + 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 + # 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')): + 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") @@ -157,35 +219,39 @@ class TimerChannel(Channel): wss.discard(websocket) await super().del_connection(websocket) - async def send_subathon_update(self, communityid: int, payload: SubathonPayload, websocket=None): + 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, + "type": "DO", + "method": "setTimer", + "args": payload, }, - websocket=ws + websocket=ws, ) - async def send_subathon_ended(self, communityid: int, payload: SubathonPayload, websocket=None): + 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, + "type": "DO", + "method": "endTimer", + "args": payload, }, - websocket=ws + 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': {}, + "type": "DO", + "method": "noTimer", + "args": {}, }, - websocket=ws + websocket=ws, ) diff --git a/subathon/subathon.py b/subathon/subathon.py index fc30c43..53b3d7c 100644 --- a/subathon/subathon.py +++ b/subathon/subathon.py @@ -1,10 +1,18 @@ from asyncio import Event from datetime import datetime, timedelta +from data.queries import JOINTYPE 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 +from .data import ( + SubathonData, + Subathon, + RunningSubathon, + SubathonContribution, + SubathonGoal, +) + class ActiveSubathon: def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None): @@ -17,7 +25,7 @@ class ActiveSubathon: @property def name(self): - return self.subathondata.name or 'Subathon' + return self.subathondata.name or "Subathon" async def check_cap(self): if not (cap := self.subathondata.timecap): @@ -32,7 +40,7 @@ class ActiveSubathon: """ Return True if the subathon duration has exceeded its earned time. """ - return (await self.get_remaining() <= 0) + return await self.get_remaining() <= 0 async def pause(self): """ @@ -53,10 +61,14 @@ class ActiveSubathon: 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) + 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) + 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: @@ -75,7 +87,7 @@ class ActiveSubathon: # Add running duration if required if self.runningdata: now = utc_now() - added = int( (now - self.runningdata.last_started).total_seconds() ) + added = int((now - self.runningdata.last_started).total_seconds()) duration += added earned = await self.get_earned() @@ -111,23 +123,108 @@ class ActiveSubathon: 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: + 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 + 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') + 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') + goals = await SubathonGoal.fetch_where( + subathon_id=self.subathondata.subathon_id + ).order_by("required_score") return goals + async def get_total_subs(self) -> int: + """ + Get the total number of sub points + contributed to this subathon. + """ + pointcol = ( + "CASE " + "WHEN tier = 1000 THEN 1 " + "WHEN tier = 2000 THEN 2 " + "WHEN tier = 3000 THEN 6 " + "END" + ) + total_points = 0 + + result = await ( + SubathonContribution.table.select_where( + subathoid=self.subathondata.subathon_id + ) + .join( + "subscribe_events", + using=("eventid",), + join_type=JOINTYPE.INNER, + ) + .select(total=f"SUM({pointcol})") + .with_no_adapter() + ) + + total_points += result["total"] + + result = await ( + SubathonContribution.table.select_where( + subathoid=self.subathondata.subathon_id + ) + .join( + "subscribe_message_events", + using=("eventid",), + join_type=JOINTYPE.INNER, + ) + .select(total=f"SUM({pointcol})") + .with_no_adapter() + ) + + total_points += result["total"] + + result = await ( + SubathonContribution.table.select_where( + subathoid=self.subathondata.subathon_id + ) + .join( + "subscribe_gift_events", + using=("eventid",), + join_type=JOINTYPE.INNER, + ) + .select(total=f"SUM(gifted_count * ({pointcol}))") + .with_no_adapter() + ) + + total_points += result["total"] + + return total_points + + async def get_total_bits(self) -> int: + """ + Get the total number of bits + contributed to this subathon. + """ + result = await ( + SubathonContribution.table.select_where( + subathoid=self.subathondata.subathon_id + ) + .join( + "bits_events", + using=("eventid",), + join_type=JOINTYPE.INNER, + ) + .select(total="SUM(bits)") + .with_no_adapter() + ) + return result["total"] + class SubathonRegistry: def __init__(self, data: SubathonData, profiler: ProfilesRegistry): @@ -144,5 +241,3 @@ class SubathonRegistry: running = await RunningSubathon.fetch(subathondata.subathon_id) subba = ActiveSubathon(subathondata, running) return subba - -