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 - -