from django.db import models from django.core.exceptions import ValidationError from django.db.models import Q, Count, Prefetch, F, Sum, Max import hashlib from cards.models import Card from accounts.models import FriendCode from datetime import timedelta from django.utils import timezone class TradeOfferManager(models.Manager): def get_queryset(self): qs = super().get_queryset().select_related( "initiated_by__user", ).prefetch_related( "trade_offer_have_cards__card", "trade_offer_want_cards__card", "acceptances", "acceptances__requested_card", "acceptances__offered_card", "acceptances__accepted_by__user", ) cutoff = timezone.now() - timedelta(days=28) qs = qs.filter(created_at__gte=cutoff) return qs.order_by("-updated_at") class TradeOfferAllManager(models.Manager): def get_queryset(self): # Return all trade offers without filtering by the cutoff. return super().get_queryset() class TradeOffer(models.Model): objects = TradeOfferManager() all_offers = TradeOfferAllManager() # New unfiltered manager id = models.AutoField(primary_key=True) is_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' ) rarity_icon = models.CharField(max_length=8, null=True) rarity_level = models.IntegerField(null=True) 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) 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_rarity_fields(self): """ Recalculates and updates the rarity_level and rarity_icon fields based on the associated have_cards and want_cards. Enforces that all cards in the trade offer share the same rarity. Uses the first card's rarity details to update both fields. """ # Gather all cards from both sides. cards = list(self.have_cards.all()) + list(self.want_cards.all()) if not cards: return # Enforce same rarity across all cards. rarity_levels = {card.rarity_level for card in cards} if len(rarity_levels) > 1: raise ValidationError("All cards in a trade offer must have the same rarity.") first_card = cards[0] if self.rarity_level != first_card.rarity_level or self.rarity_icon != first_card.rarity_icon: self.rarity_level = first_card.rarity_level self.rarity_icon = first_card.rarity_icon # Use super().save() here to avoid recursion. super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"]) 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) qty_accepted = models.PositiveIntegerField(default=0, editable=False) def __str__(self): return f"{self.card.name} x{self.quantity} (Accepted: {self.qty_accepted})" def save(self, *args, **kwargs): super().save(*args, **kwargs) self.trade_offer.update_rarity_fields() def delete(self, *args, **kwargs): trade_offer = self.trade_offer super().delete(*args, **kwargs) trade_offer.update_rarity_fields() 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) qty_accepted = models.PositiveIntegerField(default=0, editable=False) def __str__(self): return f"{self.card.name} x{self.quantity} (Accepted: {self.qty_accepted})" def save(self, *args, **kwargs): super().save(*args, **kwargs) self.trade_offer.update_rarity_fields() def delete(self, *args, **kwargs): trade_offer = self.trade_offer super().delete(*args, **kwargs) trade_offer.update_rarity_fields() 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' ) # 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' ) state = models.CharField( max_length=25, choices=AcceptanceState.choices, default=AcceptanceState.ACCEPTED ) 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) # A mapping for alternate action labels ALTERNATE_ACTION_LABELS = { AcceptanceState.REJECTED_BY_INITIATOR: "Cancel This Trade", AcceptanceState.REJECTED_BY_ACCEPTOR: "Cancel This Trade", # Optionally add alternate labels for other states: AcceptanceState.ACCEPTED: "Accept This Trade Offer", AcceptanceState.SENT: "Mark as Sent", AcceptanceState.RECEIVED: "Mark as Received", AcceptanceState.THANKED_BY_INITIATOR: "Send Thanks", AcceptanceState.THANKED_BY_ACCEPTOR: "Send Thanks", AcceptanceState.THANKED_BY_BOTH: "Send Thanks", } @classmethod def get_action_label_for_state(cls, state_value): """ Returns the alternate action label for the provided state_value. If no alternate label exists, falls back to the default label. """ default = dict(cls.AcceptanceState.choices).get(state_value, state_value) return cls.ALTERNATE_ACTION_LABELS.get(state_value, default) @property def action_label(self): """ For the current acceptance state, return the alternate action label. """ return self.get_action_label_for_state(self.state) @property def is_completed(self): 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): return self.state == self.AcceptanceState.THANKED_BY_BOTH @property def is_rejected(self): return self.state in { self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_ACCEPTOR, } @property def is_completed_or_rejected(self): return self.is_completed or self.is_rejected @property def is_active(self): return not self.is_completed_or_rejected def clean(self): # Validate that the requested and offered cards exist in the through tables. 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).") 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).") 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) 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.") 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): 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: return next(index for index, choice in enumerate(self.AcceptanceState.choices) if choice[0] == self.state) + 1 def update_state(self, new_state, user): if new_state not in [choice[0] for choice in self.AcceptanceState.choices]: raise ValueError(f"'{new_state}' is not a valid state.") 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)] 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: 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): 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 [(state, self.AcceptanceState(state).label) for state in allowed]