from django.db import models from django.core.exceptions import ValidationError from django.db.models import Q import hashlib from cards.models import Card from accounts.models import FriendCode class TradeOffer(models.Model): id = models.AutoField(primary_key=True) manually_closed = models.BooleanField(default=False, db_index=True) hash = models.CharField(max_length=9, editable=False) initiated_by = models.ForeignKey( "accounts.FriendCode", on_delete=models.PROTECT, related_name='initiated_trade_offers' ) # Use custom through models to support multiples. want_cards = models.ManyToManyField( "cards.Card", related_name='trade_offers_want', through="TradeOfferWantCard" ) have_cards = models.ManyToManyField( "cards.Card", related_name='trade_offers_have', through="TradeOfferHaveCard" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) # New denormalized fields for aggregated counts total_have_quantity = models.PositiveIntegerField(default=0, editable=False) total_want_quantity = models.PositiveIntegerField(default=0, editable=False) total_have_accepted = models.PositiveIntegerField(default=0, editable=False) total_want_accepted = models.PositiveIntegerField(default=0, editable=False) def __str__(self): want_names = ", ".join([x.name for x in self.want_cards.all()]) have_names = ", ".join([x.name for x in self.have_cards.all()]) return f"Want: {want_names} -> Have: {have_names}" def save(self, *args, **kwargs): is_new = self.pk is None super().save(*args, **kwargs) if is_new and not self.hash: self.hash = hashlib.md5((str(self.id) + "z").encode("utf-8")).hexdigest()[:8] + "z" super().save(update_fields=["hash"]) def update_aggregates(self): """ Recalculate and update aggregated fields from related have/want cards and acceptances. """ from django.db.models import Sum from trades.models import TradeAcceptance # Calculate total quantities from through models have_agg = self.trade_offer_have_cards.aggregate(total=Sum("quantity")) want_agg = self.trade_offer_want_cards.aggregate(total=Sum("quantity")) self.total_have_quantity = have_agg["total"] or 0 self.total_want_quantity = want_agg["total"] or 0 # Define acceptance states that count as active. active_states = [ TradeAcceptance.AcceptanceState.ACCEPTED, TradeAcceptance.AcceptanceState.SENT, TradeAcceptance.AcceptanceState.RECEIVED, TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, ] # Compute accepted counts based on matching card IDs. have_card_ids = list(self.trade_offer_have_cards.values_list("card_id", flat=True)) want_card_ids = list(self.trade_offer_want_cards.values_list("card_id", flat=True)) self.total_have_accepted = TradeAcceptance.objects.filter( trade_offer=self, state__in=active_states, requested_card_id__in=have_card_ids, ).count() self.total_want_accepted = TradeAcceptance.objects.filter( trade_offer=self, state__in=active_states, offered_card_id__in=want_card_ids, ).count() # Save updated aggregate values so they are denormalized in the database. self.save(update_fields=[ "total_have_quantity", "total_want_quantity", "total_have_accepted", "total_want_accepted", ]) @property def is_closed(self): if self.manually_closed: return True # Utilize denormalized fields for faster check. return not (self.total_have_accepted < self.total_have_quantity and self.total_want_accepted < self.total_want_quantity) class Meta: indexes = [ models.Index(fields=['manually_closed']), ] class TradeOfferHaveCard(models.Model): """ Through model for TradeOffer.have_cards. Represents the card the initiator is offering along with the quantity available. """ trade_offer = models.ForeignKey( TradeOffer, on_delete=models.CASCADE, related_name='trade_offer_have_cards', db_index=True ) card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True) quantity = models.PositiveIntegerField(default=1) def __str__(self): return f"{self.card.name} x{self.quantity}" class Meta: unique_together = ("trade_offer", "card") class TradeOfferWantCard(models.Model): """ Through model for TradeOffer.want_cards. Represents the card the initiator is requesting along with the quantity requested. """ trade_offer = models.ForeignKey( TradeOffer, on_delete=models.CASCADE, related_name='trade_offer_want_cards' ) card = models.ForeignKey("cards.Card", on_delete=models.PROTECT) quantity = models.PositiveIntegerField(default=1) def __str__(self): return f"{self.card.name} x{self.quantity}" class Meta: unique_together = ("trade_offer", "card") class TradeAcceptance(models.Model): class AcceptanceState(models.TextChoices): ACCEPTED = 'ACCEPTED', 'Accepted' SENT = 'SENT', 'Sent' RECEIVED = 'RECEIVED', 'Received' THANKED_BY_INITIATOR = 'THANKED_BY_INITIATOR', 'Thanked by Initiator' THANKED_BY_ACCEPTOR = 'THANKED_BY_ACCEPTOR', 'Thanked by Acceptor' THANKED_BY_BOTH = 'THANKED_BY_BOTH', 'Thanked by Both' REJECTED_BY_INITIATOR = 'REJECTED_BY_INITIATOR', 'Rejected by Initiator' REJECTED_BY_ACCEPTOR = 'REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor' trade_offer = models.ForeignKey( TradeOffer, on_delete=models.CASCADE, related_name='acceptances', db_index=True ) accepted_by = models.ForeignKey( "accounts.FriendCode", on_delete=models.PROTECT, related_name='trade_acceptances' ) # The acceptor selects one card the initiator is offering (from have_cards) requested_card = models.ForeignKey( "cards.Card", on_delete=models.PROTECT, related_name='accepted_requested', db_index=True ) # And one card from the initiator's wanted cards (from want_cards) offered_card = models.ForeignKey( "cards.Card", on_delete=models.PROTECT, related_name='accepted_offered', db_index=True ) state = models.CharField( max_length=25, choices=AcceptanceState.choices, default=AcceptanceState.ACCEPTED, db_index=True ) hash = models.CharField(max_length=9, editable=False, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def mark_thanked(self, friend_code): """ Mark this acceptance as "thanked" by the given friend_code. Allowed transitions: - If the current state is RECEIVED: * If the initiator thanks, transition to THANKED_BY_INITIATOR. * If the acceptor thanks, transition to THANKED_BY_ACCEPTOR. - If already partially thanked: * If state is THANKED_BY_INITIATOR and the acceptor thanks, transition to THANKED_BY_BOTH. * If state is THANKED_BY_ACCEPTOR and the initiator thanks, transition to THANKED_BY_BOTH. Only parties involved in the trade (either the initiator or the acceptor) can mark it as thanked. """ if self.state not in [self.AcceptanceState.RECEIVED, self.AcceptanceState.THANKED_BY_INITIATOR, self.AcceptanceState.THANKED_BY_ACCEPTOR]: raise ValidationError("Cannot mark thanked in the current state.") if friend_code == self.trade_offer.initiated_by: # Initiator is marking thanks. if self.state == self.AcceptanceState.RECEIVED: self.state = self.AcceptanceState.THANKED_BY_INITIATOR elif self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR: self.state = self.AcceptanceState.THANKED_BY_BOTH elif self.state == self.AcceptanceState.THANKED_BY_INITIATOR: # Already thanked by the initiator. return elif friend_code == self.accepted_by: # Acceptor is marking thanks. if self.state == self.AcceptanceState.RECEIVED: self.state = self.AcceptanceState.THANKED_BY_ACCEPTOR elif self.state == self.AcceptanceState.THANKED_BY_INITIATOR: self.state = self.AcceptanceState.THANKED_BY_BOTH elif self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR: # Already thanked by the acceptor. return else: from django.core.exceptions import PermissionDenied raise PermissionDenied("You are not a party to this trade acceptance.") self.save(update_fields=["state"]) @property def is_completed(self): """ Computed boolean property indicating whether the trade acceptance has been marked as thanked by one or both parties. """ return self.state in { self.AcceptanceState.RECEIVED, self.AcceptanceState.THANKED_BY_INITIATOR, self.AcceptanceState.THANKED_BY_ACCEPTOR, self.AcceptanceState.THANKED_BY_BOTH, } @property def is_thanked(self): """ Computed boolean property indicating whether the trade acceptance has been marked as thanked by one or both parties. """ return self.state == self.AcceptanceState.THANKED_BY_BOTH @property def is_rejected(self): """ Computed boolean property that is True if the trade acceptance has been rejected by either the initiator or the acceptor. """ return self.state in { self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_ACCEPTOR, } @property def is_completed_or_rejected(self): """ Computed boolean property that is True if the trade acceptance is either completed (i.e., thanked) or rejected. """ return self.is_completed or self.is_rejected @property def is_active(self): """ Computed boolean property that is True if the trade acceptance is still active, meaning it is neither completed (thanked) nor rejected. """ return not self.is_completed_or_rejected def clean(self): """ Validate that: - The requested_card is associated with the trade offer's have_cards (via the through model). - The offered_card is associated with the trade offer's want_cards (via the through model). - The trade offer is not already closed. - The total number of active acceptances for each chosen card does not exceed the available quantity. """ # Validate that requested_card is in trade_offer.have_cards try: have_through_obj = self.trade_offer.trade_offer_have_cards.get(card=self.requested_card) except TradeOfferHaveCard.DoesNotExist: raise ValidationError("The requested card must be one of the trade offer's available cards (have_cards).") # Validate that offered_card is in trade_offer.want_cards try: want_through_obj = self.trade_offer.trade_offer_want_cards.get(card=self.offered_card) except TradeOfferWantCard.DoesNotExist: raise ValidationError("The offered card must be one of the trade offer's requested cards (want_cards).") # For new acceptances, do not allow creation if the trade offer is closed. if not self.pk and self.trade_offer.is_closed: raise ValidationError("This trade offer is closed. No more acceptances are allowed.") active_states = [ self.AcceptanceState.ACCEPTED, self.AcceptanceState.SENT, self.AcceptanceState.RECEIVED, self.AcceptanceState.THANKED_BY_INITIATOR, self.AcceptanceState.THANKED_BY_ACCEPTOR, self.AcceptanceState.THANKED_BY_BOTH, ] active_acceptances = self.trade_offer.acceptances.filter(state__in=active_states) if self.pk: active_acceptances = active_acceptances.exclude(pk=self.pk) # Count active acceptances for the requested card. requested_count = active_acceptances.filter(requested_card=self.requested_card).count() if requested_count >= have_through_obj.quantity: raise ValidationError("This requested card has been fully accepted.") # Count active acceptances for the offered card. offered_count = active_acceptances.filter(offered_card=self.offered_card).count() if offered_count >= want_through_obj.quantity: raise ValidationError("This offered card has already been fully used.") def get_step_number(self): """ Return the step number for the current state. """ if self.state in [ self.AcceptanceState.THANKED_BY_INITIATOR, self.AcceptanceState.THANKED_BY_ACCEPTOR, ]: return 4 elif self.state in [ self.AcceptanceState.THANKED_BY_BOTH, ]: return 5 elif self.state in [ self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_ACCEPTOR, ]: return 0 else: # .choices is a list of tuples, so we need to find the index of the tuple that contains the state. return (next(index for index, choice in enumerate(self.AcceptanceState.choices) if choice[0] == self.state) + 1) def update_state(self, new_state, user): """ Update the trade acceptance state. """ if new_state not in [choice[0] for choice in self.AcceptanceState.choices]: raise ValueError(f"'{new_state}' is not a valid state.") # Terminal states: no further transitions allowed. if self.state in [ self.AcceptanceState.THANKED_BY_BOTH, self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_ACCEPTOR ]: raise ValueError(f"No transitions allowed from the terminal state '{self.state}'.") allowed = [x for x,y in self.get_allowed_state_transitions(user)] print(allowed) print(new_state) if new_state not in allowed: raise ValueError(f"Transition from {self.state} to {new_state} is not allowed.") self.state = new_state self.save(update_fields=["state"]) def save(self, *args, **kwargs): is_new = self.pk is None super().save(*args, **kwargs) if is_new and not self.hash: # Append "y" so all trade acceptance hashes differ from trade offers. self.hash = hashlib.md5((str(self.id) + "y").encode("utf-8")).hexdigest()[:8] + "y" super().save(update_fields=["hash"]) def __str__(self): return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, " f"accepted_by={self.accepted_by}, " f"requested_card={self.requested_card}, " f"offered_card={self.offered_card}, state={self.state})") def get_allowed_state_transitions(self, user): """ Returns a list of allowed state transitions as tuples (value, display_label) based on the current state of this trade acceptance. """ if self.trade_offer.initiated_by in user.friend_codes.all(): allowed_transitions = { self.AcceptanceState.ACCEPTED: { self.AcceptanceState.SENT, self.AcceptanceState.REJECTED_BY_INITIATOR, }, self.AcceptanceState.SENT: { self.AcceptanceState.REJECTED_BY_INITIATOR, }, self.AcceptanceState.RECEIVED: { self.AcceptanceState.THANKED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_INITIATOR, }, self.AcceptanceState.THANKED_BY_INITIATOR: { self.AcceptanceState.REJECTED_BY_INITIATOR, }, self.AcceptanceState.THANKED_BY_ACCEPTOR: { self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.THANKED_BY_BOTH, }, } elif self.accepted_by in user.friend_codes.all(): allowed_transitions = { self.AcceptanceState.ACCEPTED: { self.AcceptanceState.REJECTED_BY_ACCEPTOR, }, self.AcceptanceState.SENT: { self.AcceptanceState.RECEIVED, self.AcceptanceState.REJECTED_BY_ACCEPTOR, }, self.AcceptanceState.RECEIVED: { self.AcceptanceState.THANKED_BY_ACCEPTOR, self.AcceptanceState.REJECTED_BY_ACCEPTOR, }, self.AcceptanceState.THANKED_BY_ACCEPTOR: { self.AcceptanceState.REJECTED_BY_ACCEPTOR, }, self.AcceptanceState.THANKED_BY_INITIATOR: { self.AcceptanceState.REJECTED_BY_ACCEPTOR, self.AcceptanceState.THANKED_BY_BOTH, }, } else: allowed_transitions = {} allowed = allowed_transitions.get(self.state, {}) # Return as a list of tuples (state_value, human-readable label) return [(state, self.AcceptanceState(state).label) for state in allowed]