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) manually_closed = models.BooleanField(default=False) hash = models.CharField(max_length=8, 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).encode('utf-8')).hexdigest()[:8] super().save(update_fields=["hash"]) @property def is_closed(self): if self.manually_closed: return True from .models import TradeAcceptance # local import to avoid circular dependencies active_states = [ TradeAcceptance.AcceptanceState.ACCEPTED, TradeAcceptance.AcceptanceState.SENT, TradeAcceptance.AcceptanceState.RECEIVED, TradeAcceptance.AcceptanceState.COMPLETED ] closed_have = True for through_obj in self.trade_offer_have_cards.all(): accepted_count = self.acceptances.filter( requested_card=through_obj.card, state__in=active_states ).count() if accepted_count < through_obj.quantity: closed_have = False break closed_want = True for through_obj in self.trade_offer_want_cards.all(): accepted_count = self.acceptances.filter( offered_card=through_obj.card, state__in=active_states ).count() if accepted_count < through_obj.quantity: closed_want = False break return closed_have or closed_want 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' ) card = models.ForeignKey("cards.Card", on_delete=models.PROTECT) quantity = models.PositiveIntegerField(default=1) def __str__(self): return f"{self.card.name} x{self.quantity} (Have side for offer {self.trade_offer.hash})" 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) def __str__(self): return f"{self.card.name} x{self.quantity} (Want side for offer {self.trade_offer.hash})" class Meta: unique_together = ("trade_offer", "card") class TradeAcceptance(models.Model): class AcceptanceState(models.TextChoices): ACCEPTED = 'ACCEPTED', 'Accepted' SENT = 'SENT', 'Sent' RECEIVED = 'RECEIVED', 'Received' COMPLETED = 'COMPLETED', 'Completed' 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' ) 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 ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def clean(self): """ Validate that: - The requested_card is associated with the trade offer's have_cards (via the through model). - The offered_card is associated with the trade offer's want_cards (via the through model). - The trade offer is not already closed. - The total number of active acceptances for each chosen card does not exceed the available quantity. """ # Validate that requested_card is in trade_offer.have_cards 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).") # Validate that offered_card is in trade_offer.want_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).") # For new acceptances, do not allow creation if the trade offer is closed. 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.COMPLETED, ] active_acceptances = self.trade_offer.acceptances.filter(state__in=active_states) if self.pk: active_acceptances = active_acceptances.exclude(pk=self.pk) # Count active acceptances for the requested card. 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.") # Count active acceptances for the offered card. 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 update_state(self, new_state): """ Update the trade acceptance state. Allowed transitions: - ACCEPTED -> SENT - SENT -> RECEIVED - RECEIVED -> COMPLETED Additionally, from any active state a transition to: REJECTED_BY_INITIATOR or REJECTED_BY_ACCEPTOR is allowed. Once in COMPLETED or any rejection state, no further transitions are allowed. """ if new_state not in [choice[0] for choice in self.AcceptanceState.choices]: raise ValueError(f"'{new_state}' is not a valid state.") # Terminal states: no further transitions allowed. if self.state in [ self.AcceptanceState.COMPLETED, self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_ACCEPTOR ]: raise ValueError(f"No transitions allowed from the terminal state '{self.state}'.") allowed_transitions = { self.AcceptanceState.ACCEPTED: { self.AcceptanceState.SENT, self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_ACCEPTOR, }, self.AcceptanceState.SENT: { self.AcceptanceState.RECEIVED, self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_ACCEPTOR, }, self.AcceptanceState.RECEIVED: { self.AcceptanceState.COMPLETED, self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_ACCEPTOR, }, } if new_state not in allowed_transitions.get(self.state, {}): raise ValueError(f"Transition from {self.state} to {new_state} is not allowed.") self.state = new_state self.save(update_fields=["state"]) 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})") class Meta: # Unique constraints have been removed because validations now allow # multiple active acceptances per card based on the available quantity. pass