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 pkmntrade_club.cards.models import Card from pkmntrade_club.accounts.models import FriendCode from datetime import timedelta from django.utils import timezone import uuid def generate_tradeoffer_hash(): """ Generates a unique 9-character hash for a TradeOffer. The last character 'z' indicates its type. """ return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "z" def generate_tradeacceptance_hash(): """ Generates a unique 9-character hash for a TradeAcceptance. The last character 'y' indicates its type. """ return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "y" class TradeOfferManager(models.Manager): def get_queryset(self): qs = super().get_queryset() # Prefetch for have_cards (through model: TradeOfferHaveCard) # Ensures 'card' is select_related and 'Meta.ordering' is respected/applied. prefetch_have_cards = Prefetch( 'trade_offer_have_cards', queryset=TradeOfferHaveCard.objects.select_related('card').order_by('card__name') ) # Prefetch for want_cards (through model: TradeOfferWantCard) # Ensures 'card' is select_related and 'Meta.ordering' is respected/applied. prefetch_want_cards = Prefetch( 'trade_offer_want_cards', queryset=TradeOfferWantCard.objects.select_related('card').order_by('card__name') ) # Prefetch for acceptances # Ensures related 'accepted_by__user', 'requested_card', 'offered_card' are fetched. prefetch_acceptances = Prefetch( 'acceptances', queryset=TradeAcceptance.objects.select_related( 'accepted_by__user', 'requested_card', 'offered_card' ).order_by('-created_at') # Sensible default ordering for acceptances ) qs = qs.select_related( "initiated_by__user", # Fetches FriendCode and its related CustomUser ).prefetch_related( prefetch_have_cards, prefetch_want_cards, prefetch_acceptances, # If direct access like offer.have_cards.all() (the M2M to Card, not through model) # is heavily used AND causes N+1s (e.g. via __str__), uncomment these: Prefetch('have_cards'), Prefetch('want_cards'), ) return qs.order_by("-updated_at") # Default ordering for TradeOffer querysets class TradeOffer(models.Model): objects = TradeOfferManager() 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) image = models.ImageField(upload_to='trade_offers/', null=True, blank=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): if not self.hash: self.hash = generate_tradeoffer_hash() super().save(*args, **kwargs) 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 first_card.rarity_level > 5: raise ValidationError("Cannot trade cards above one-star rarity.") 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"]) @property def have_cards_available(self): # Returns the list of have_cards (through objects) that still have available quantity. return [item for item in self.trade_offer_have_cards.all() if item.quantity > item.qty_accepted] @property def want_cards_available(self): # Returns the list of want_cards (through objects) that still have available quantity. return [item for item in self.trade_offer_want_cards.all() if item.quantity > item.qty_accepted] 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) @property def qty_available(self): return self.quantity - self.qty_accepted def __str__(self): return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity_icon} {self.card.name}" def save(self, *args, **kwargs): self.trade_offer.update_rarity_fields() super().save(*args, **kwargs) 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") ordering = ['card__name'] 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) @property def qty_available(self): return self.quantity - self.qty_accepted def __str__(self): return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity_icon} {self.card.name}" 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") ordering = ['card__name'] 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' # DRY improvement: define active states once as a class-level constant. POSITIVE_STATES = [ AcceptanceState.ACCEPTED, AcceptanceState.SENT, AcceptanceState.RECEIVED, AcceptanceState.THANKED_BY_INITIATOR, AcceptanceState.THANKED_BY_ACCEPTOR, AcceptanceState.THANKED_BY_BOTH, ] 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' ) requested_card = models.ForeignKey( "cards.Card", on_delete=models.PROTECT, related_name='accepted_requested' ) 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: "Reject This Trade", AcceptanceState.REJECTED_BY_ACCEPTOR: "Reject This Trade", # Optionally add alternate labels for other states: AcceptanceState.ACCEPTED: "Accept This Trade Offer", AcceptanceState.SENT: "Mark Sent", AcceptanceState.RECEIVED: "Mark Received", AcceptanceState.THANKED_BY_INITIATOR: "Send Thanks", AcceptanceState.THANKED_BY_ACCEPTOR: "Send Thanks", AcceptanceState.THANKED_BY_BOTH: "Send Thanks", } ALTERNATE_ACTION_LABELS_2 = { AcceptanceState.REJECTED_BY_INITIATOR: "Rejected this Trade", AcceptanceState.REJECTED_BY_ACCEPTOR: "Rejected this Trade", AcceptanceState.ACCEPTED: "Accepted this Trade", AcceptanceState.SENT: "Sent the Card", AcceptanceState.RECEIVED: "Received the Card and Responded", AcceptanceState.THANKED_BY_INITIATOR: "Sent Thanks", AcceptanceState.THANKED_BY_ACCEPTOR: "Sent Thanks", AcceptanceState.THANKED_BY_BOTH: "Sent 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 next_action_label(self): """ Returns what the next action label would be based on the current state. """ if self.state == self.AcceptanceState.ACCEPTED: return self.get_action_label_for_state(self.AcceptanceState.SENT) elif self.state == self.AcceptanceState.SENT: return self.get_action_label_for_state(self.AcceptanceState.RECEIVED) elif self.state == self.AcceptanceState.RECEIVED or self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR or self.state == self.AcceptanceState.THANKED_BY_INITIATOR: return self.get_action_label_for_state(self.AcceptanceState.THANKED_BY_BOTH) else: return None @classmethod def get_action_label_for_state_2(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_2.get(state_value, default) @property def action_label_2(self): """ For the current acceptance state, return the alternate action label. """ return self.get_action_label_for_state_2(self.state) @property def is_initiator_state(self): return self.state in [self.AcceptanceState.SENT.value, self.AcceptanceState.THANKED_BY_INITIATOR.value, self.AcceptanceState.THANKED_BY_BOTH.value] @property def is_acceptor_state(self): return self.state in [self.AcceptanceState.ACCEPTED.value, self.AcceptanceState.RECEIVED.value, self.AcceptanceState.THANKED_BY_ACCEPTOR.value, self.AcceptanceState.THANKED_BY_BOTH.value] @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): from django.core.exceptions import ValidationError try: have_card = self.trade_offer.trade_offer_have_cards.get(card_id=self.requested_card_id) except TradeOfferHaveCard.DoesNotExist: raise ValidationError("The requested card must be one of the trade offer's available cards (have_cards).") try: want_card = self.trade_offer.trade_offer_want_cards.get(card_id=self.offered_card_id) except TradeOfferWantCard.DoesNotExist: raise ValidationError("The offered card must be one of the trade offer's requested cards (want_cards).") # Only perform these validations on creation (when self.pk is None). if self.pk is None: if self.trade_offer.is_closed: raise ValidationError("This trade offer is closed. No more acceptances are allowed.") # Use direct comparison with qty_accepted and quantity. if have_card.qty_accepted >= have_card.quantity: raise ValidationError("The requested card has no available quantity.") if want_card.qty_accepted >= want_card.quantity: raise ValidationError("The offered card has no available quantity.") 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 (new_state == self.AcceptanceState.THANKED_BY_ACCEPTOR and self.state == self.AcceptanceState.THANKED_BY_INITIATOR) or \ (new_state == self.AcceptanceState.THANKED_BY_INITIATOR and self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR): new_state = self.AcceptanceState.THANKED_BY_BOTH 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._actioning_user = user self.state = new_state self.save(update_fields=["state"]) def save(self, *args, **kwargs): if not self.hash: self.hash = generate_tradeacceptance_hash() super().save(*args, **kwargs) 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.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, #allow early thanks (uses THANKED_BY_ACCEPTOR state) self.AcceptanceState.REJECTED_BY_ACCEPTOR }, self.AcceptanceState.THANKED_BY_ACCEPTOR: { }, self.AcceptanceState.THANKED_BY_INITIATOR: { self.AcceptanceState.THANKED_BY_BOTH, }, } else: allowed_transitions = {} allowed = allowed_transitions.get(self.state, {}) return [(state, self.AcceptanceState(state).label) for state in allowed]