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"]) # New derived properties for available cards @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) 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' # 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 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): 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]