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) 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' ) # 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) 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"]) 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) # New field to track number of accepted cards for this entry. qty_accepted = models.PositiveIntegerField(default=0, editable=False) def __str__(self): return f"{self.card.name} x{self.quantity} (Accepted: {self.qty_accepted})" 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) # New field for tracking accepted count. qty_accepted = models.PositiveIntegerField(default=0, editable=False) def __str__(self): return f"{self.card.name} x{self.quantity} (Accepted: {self.qty_accepted})" 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) @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]