diff --git a/api/serializers.py b/api/serializers.py index 5362de8..bfd3ddf 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -2,6 +2,7 @@ from rest_framework import serializers from django.contrib.auth import get_user_model from boxofficefantasy.models import Movie, Season from draft.models import DraftSession, DraftSessionSettings, DraftPick +from boxofficefantasy.integrations.tmdb import get_tmdb_movie_by_imdb User = get_user_model() @@ -16,10 +17,27 @@ class UserSerializer(serializers.ModelSerializer): return f"{obj.first_name} {obj.last_name}".strip() class MovieSerializer(serializers.ModelSerializer): + tmdb_data = serializers.SerializerMethodField() + def get_tmdb_data(self, obj): + if hasattr(obj, 'imdb_id') and obj.imdb_id: + tmdb_movie = get_tmdb_movie_by_imdb(obj.imdb_id) + if tmdb_movie: + poster_url = None + if tmdb_movie.get('poster_path'): + poster_url = f"{tmdb_movie['poster_path']}" + + return { + 'id': tmdb_movie.get('id'), + 'title': tmdb_movie.get('title'), + 'overview': tmdb_movie.get('overview'), + 'poster_url': tmdb_movie['poster_url'], + 'release_date': tmdb_movie.get('release_date'), + } + return None class Meta: model = Movie # fields = ("id", "imdb_id", "title", "year", "poster_url") - fields = ("id", "title") + fields = ("id", "title", "tmdb_data") class DraftSessionSettingsSerializer(serializers.ModelSerializer): class Meta: diff --git a/boxofficefantasy/integrations/tmdb.py b/boxofficefantasy/integrations/tmdb.py index 8e8cec6..e68b548 100644 --- a/boxofficefantasy/integrations/tmdb.py +++ b/boxofficefantasy/integrations/tmdb.py @@ -12,13 +12,15 @@ tmdb.language = "en" TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500" -def get_tmdb_movie_by_imdb(imdb_id): +def get_tmdb_movie_by_imdb(imdb_id, cache_poster=True): """ Fetch TMDb metadata by IMDb ID, using cache to avoid redundant API calls. """ cache_key = f"tmdb:movie:{imdb_id}" cached = cache.get(cache_key) if cached: + if cache_poster and not cached.get('poster_url'): + cached['poster_url'] = cache_tmdb_poster(cached['poster_path']) return cached results = Movie().external(external_id=imdb_id, external_source="imdb_id") @@ -27,6 +29,8 @@ def get_tmdb_movie_by_imdb(imdb_id): movie_data = results.movie_results[0] cache.set(cache_key, movie_data, timeout=60 * 60 * 24) # 1 day + if cache_poster: + movie_data['poster_url'] = cache_tmdb_poster(movie_data['poster_path']) return movie_data diff --git a/draft/constants.py b/draft/constants.py index db339bc..b08c189 100644 --- a/draft/constants.py +++ b/draft/constants.py @@ -11,6 +11,7 @@ class DraftMessage(StrEnum): USER_JOIN_INFORM = "user.join.inform" # server -> client USER_LEAVE_INFORM = "user.leave.inform" USER_IDENTIFICATION_INFORM = "user.identification.inform" # server -> client (tells socket "you are X", e.g. after connect) # server -> client + USER_STATE_INFORM = "user.state.inform" # Phase control PHASE_CHANGE_INFORM = "phase.change.inform" # server -> client (target phase payload) @@ -31,8 +32,10 @@ class DraftMessage(StrEnum): # Bidding (examples, adjust to your flow) BID_START_INFORM = "bid.start.inform" # server -> client (movie, ends_at) BID_START_REQUEST = "bid.start.request" # server -> client (movie, ends_at) + BID_START_REJECT = "bid.start.reject" # server -> client (movie, ends_at) BID_PLACE_REQUEST = "bid.place.request" # client -> server (amount) - BID_PLACE_CONFIRM = "bid.update.confirm" # server -> client (high bid) + BID_PLACE_REJECT = "bid.place.reject" # server -> client (high bid) + BID_PLACE_CONFIRM = "bid.place.confirm" # server -> client (high bid) BID_UPDATE_INFORM = "bid.update.inform" # server -> client (high bid) BID_END_INFORM = "bid.end.inform" # server -> client (winner) diff --git a/draft/consumers.py b/draft/consumers.py index f1a1875..02cea23 100644 --- a/draft/consumers.py +++ b/draft/consumers.py @@ -4,23 +4,19 @@ from django.core.exceptions import PermissionDenied from boxofficefantasy.models import League, Season from boxofficefantasy.views import parse_season_slug from draft.models import DraftSession, DraftSessionParticipant -import asyncio from django.contrib.auth.models import User from draft.constants import ( DraftMessage, DraftPhase, DraftGroupChannelNames, ) -from draft.state import DraftStateManager +from draft.state import DraftStateManager, DraftStateException from typing import Any import logging logger = logging.getLogger(__name__) # __name__ = module path -import random - - class DraftConsumerBase(AsyncJsonWebsocketConsumer): group_names: DraftGroupChannelNames draft_state: DraftStateManager @@ -61,6 +57,7 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): return else: await self.accept() + self.draft_state.connect_participant(self.user.username) await self.channel_layer.group_add( self.group_names.session, self.channel_name ) @@ -72,14 +69,6 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): "payload": {"user": self.user.username}, }, ) - await self.channel_layer.group_send( - self.group_names.session, - { - "type": "direct.message", - "subtype": DraftMessage.DRAFT_STATUS_INFORM, - "payload": self.draft_state.to_dict(), - }, - ) await self.channel_layer.send( self.channel_name, { @@ -88,6 +77,7 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): "payload": {"user": self.user.username}, }, ) + await self.broadcast_state() async def should_accept_user(self) -> bool: return self.user.is_authenticated @@ -106,6 +96,14 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): # --- Convenience helpers --- async def send_draft_state(self): """Send the current draft state only to this client.""" + await self.channel_layer.send( + self.channel_name, + { + "type": "direct.message", + "subtype": DraftMessage.USER_STATE_INFORM, + "payload": self.draft_state.user_state(self.user), + } + ) await self.channel_layer.send( self.channel_name, { @@ -117,6 +115,14 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): async def broadcast_state(self): """Broadcast current draft state to all in session group.""" + await self.channel_layer.group_send( + self.group_names.session, + { + "type": "broadcast.session", + "subtype": DraftMessage.USER_STATE_INFORM, + "payload": [self.draft_state.user_state(user) for user in self.draft_participants], + } + ) await self.channel_layer.group_send( self.group_names.session, { @@ -214,6 +220,18 @@ class DraftAdminConsumer(DraftConsumerBase): }, ) await self.broadcast_state() + + case DraftPhase.BIDDING: + await self.set_draft_phase(DraftPhase.BIDDING) + await self.channel_layer.group_send( + self.group_names.session, + { + "type": "broadcast.session", + "subtype": DraftMessage.PHASE_CHANGE_CONFIRM, + "payload": {"phase": self.draft_state.phase}, + }, + ) + await self.broadcast_state() case DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST: self.draft_state.draft_index_advance() @@ -245,16 +263,26 @@ class DraftAdminConsumer(DraftConsumerBase): await self.broadcast_state() case DraftMessage.BID_START_REQUEST: - self.draft_state.start_bidding() - await self.channel_layer.group_send( - self.group_names.session, - { - "type": "broadcast.session", - "subtype": DraftMessage.BID_START_INFORM, - "payload": {**self.draft_state}, - }, - ) - await self.broadcast_state() + try: + self.draft_state.start_bidding() + await self.channel_layer.group_send( + self.group_names.session, + { + "type": "broadcast.session", + "subtype": DraftMessage.BID_START_INFORM, + "payload": {**self.draft_state}, + }, + ) + await self.broadcast_state() + except DraftStateException as e: + await self.channel_layer.send( + self.channel_name, { + "type": "direct.message", + "subtype": DraftMessage.BID_START_REJECT, + "payload": {'message': str(e)} + } + ) + # === Draft logic === @@ -314,12 +342,14 @@ class DraftParticipantConsumer(DraftConsumerBase): }, }, ) + await self.broadcast_state() await super().disconnect(close_code) self.draft_state.disconnect_participant(self.user.username) await self.channel_layer.group_discard( self.group_names.session, self.channel_name ) + def should_accept_user(self): return super().should_accept_user() and self.user in self.draft_participants @@ -333,7 +363,7 @@ class DraftParticipantConsumer(DraftConsumerBase): "type": "broadcast.admin", "subtype": event_type, "payload": { - "movie_id": content.get("payload", {}).get("id"), + "movie_id": content.get("payload", {}).get("movie_id"), "user": content.get("payload", {}).get("user"), }, }, @@ -341,15 +371,26 @@ class DraftParticipantConsumer(DraftConsumerBase): if event_type == DraftMessage.BID_PLACE_REQUEST: bid_amount = content.get("payload", {}).get("bid_amount") - self.draft_state.place_bid(self.user, bid_amount) - await self.channel_layer.group_send( - self.group_names.session, - { - "type": "broadcast.session", - "subtype": DraftMessage.BID_PLACE_CONFIRM, - "payload": {**self.draft_state}, - }, - ) + try: + self.draft_state.place_bid(self.user, bid_amount) + await self.channel_layer.group_send( + self.group_names.session, + { + "type": "broadcast.session", + "subtype": DraftMessage.BID_PLACE_CONFIRM, + "payload": {'user': self.user.username, 'bid': bid_amount}, + }, + ) + except DraftStateException as e: + await self.channel_layer.group_send( + self.group_names.session, + { + "type": "broadcast.session", + "subtype": DraftMessage.BID_PLACE_REJECT, + "payload": {'user': self.user.username, 'bid': bid_amount, 'error':str(e)}, + }, + ) + await self.broadcast_state() # === Broadcast handlers === @@ -358,11 +399,8 @@ class DraftParticipantConsumer(DraftConsumerBase): # === Draft === - async def nominate(self, movie_title): ... - async def place_bid(self, amount, user): ... - - # === Example DB Access === + # === DB Access === @database_sync_to_async def add_draft_participant(self): diff --git a/draft/state.py b/draft/state.py index 86df768..87ff7e0 100644 --- a/draft/state.py +++ b/draft/state.py @@ -22,9 +22,11 @@ class DraftCache: bids: str bid_timer_start: str bid_timer_end: str + connected_participants: str _cached_properties = { "participants", + "connected_participants", "phase", "draft_order", "draft_index", @@ -32,7 +34,6 @@ class DraftCache: "bids", "bid_timer_start", "bid_timer_end", - } def __init__(self, draft_id: str, cache: BaseCache = cache): @@ -71,8 +72,7 @@ class DraftStateManager: self.session_id: str = session.hashid self.cache: DraftCache = DraftCache(self.session_id, cache) self.settings: DraftSessionSettings = session.settings - self.participants: set[User] = set(session.participants.all()) - self.connected_participants: set[User] = set() + self._participants = list(session.participants.all()) # === Phase Management === @property @@ -85,12 +85,21 @@ class DraftStateManager: # === Connected Users === + @property + def connected_participants(self): + return set(json.loads(self.cache.connected_participants or "[]")) + def connect_participant(self, username: str): - self.connected_participants.add(username) - return self.connected_participants + connected_participants = self.connected_participants + connected_participants.add(username) + self.cache.connected_participants = json.dumps(list(connected_participants)) + return connected_participants def disconnect_participant(self, username: str): - self.connected_participants.discard(username) + connected_participants = self.connected_participants + connected_participants.discard(username) + self.cache.connected_participants = json.dumps(list(connected_participants)) + return connected_participants # === Draft Order === @property @@ -107,7 +116,7 @@ class DraftStateManager: self.phase = DraftPhase.DETERMINE_ORDER self.draft_index = 0 draft_order = random.sample( - list(self.participants), len(self.participants) + list(self._participants), len(self._participants) ) self.draft_order = [user.username for user in draft_order] return self.draft_order @@ -169,18 +178,29 @@ class DraftStateManager: if isinstance(amount, str): amount = int(amount) bids = self.get_bids() - bids.append({"user":user.username, "amount":amount}) + user_state = self.user_state(user) + timestamp = int(time.time() * 1000) + if not user_state['can_bid']: + raise DraftStateException('Cannot bid') + if not user_state['remaining_budget'] > amount: + raise DraftStateException('No Budget Remaining') + if not self.get_timer_end() or not timestamp < self.get_timer_end() * 1000: + raise DraftStateException("Timer Error") + bids.append({"user":user.username, "amount":amount, 'timestamp': timestamp}) self.cache.bids = json.dumps(bids) def get_bids(self) -> dict: return json.loads(self.cache.bids or "[]") def current_movie(self) -> Movie | None: - movie_id = self.current_movie - return Movie.objects.filter(pk=movie_id).first() if movie_id else None + movie_id = self.cache.current_movie + return movie_id if movie_id else None def start_bidding(self): - + if not self.phase == DraftPhase.BIDDING: + raise DraftStateException('Not the right phase for that') + if not self.current_movie(): + raise DraftStateException('No movie nominated') seconds = self.settings.bidding_duration start_time = time.time() end_time = start_time + seconds @@ -202,6 +222,7 @@ class DraftStateManager: "draft_index": self.draft_index, "connected_participants": list(self.connected_participants), "current_movie": self.cache.current_movie, + "awards": [], "bids": self.get_bids(), "bidding_timer_end": self.get_timer_end(), "bidding_timer_start": self.get_timer_start(), @@ -209,6 +230,17 @@ class DraftStateManager: "next_picks": picks[1:] if picks else [] } + def user_state(self, user: User) -> dict: + picks = self.next_picks(include_current=True) + return { + "is_admin": user.is_staff, + "user": user.username, + "can_bid": self.phase == DraftPhase.BIDDING, + "can_nominate": self.phase == DraftPhase.NOMINATING and picks[0].get('participant') == user.username, + "movies":[], + "remaining_budget":100, + } + # def __dict__(self): # return self.get_summary() diff --git a/draft/templates/draft/room.dj.html b/draft/templates/draft/room.dj.html index 9aa5f24..de8c544 100644 --- a/draft/templates/draft/room.dj.html +++ b/draft/templates/draft/room.dj.html @@ -3,10 +3,11 @@ {% load static %} -
{% if user.is_staff %}| Poster | +Title | +Release Date | +
|---|---|---|
|
+
+
+ {m.title}
+
+
+
+
+ TMDB
+
+
+
+ {can_nominate || is_admin ? (
+
+ |
+ {m.tmdb_data.release_date} | +