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 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().select_related( "initiated_by", "initiated_by__user", ).prefetch_related( "trade_offer_have_cards__card", "trade_offer_want_cards__card", "acceptances", "acceptances__accepted_by", "acceptances__requested_card", "acceptances__offered_card", "acceptances__accepted_by__user", ) return qs.order_by("-updated_at") 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 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] @property def have_cards_available_qs(self): # Returns a queryset of TradeOfferHaveCard objects that still have available quantity return self.trade_offer_have_cards.filter(quantity__gt=F("qty_accepted")).select_related("card") @property def want_cards_available_qs(self): # Returns a queryset of TradeOfferWantCard objects that still have available quantity return self.trade_offer_want_cards.filter(quantity__gt=F("qty_accepted")).select_related("card") 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.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") 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.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") 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]