pkmntrade.club/trades/models.py

306 lines
12 KiB
Python

from django.db import models
from django.core.exceptions import ValidationError
from django.db.models import Q, Count, Prefetch, F, Sum
import hashlib
from cards.models import Card
from accounts.models import FriendCode
class TradeOfferManager(models.Manager):
def get_queryset(self):
queryset = super().get_queryset().select_related(
"initiated_by__user",
).prefetch_related(
"trade_offer_have_cards__card",
"trade_offer_want_cards__card",
"acceptances",
"acceptances__requested_card",
"acceptances__offered_card",
"acceptances__accepted_by__user",
).order_by("-updated_at")
return queryset
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)
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]