pkmntrade.club/src/pkmntrade_club/trades/models.py

468 lines
18 KiB
Python

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 pkmntrade_club.cards.models import Card
from pkmntrade_club.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 first_card.rarity_level > 5:
raise ValidationError("Cannot trade cards above one-star rarity.")
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.cardnum} {self.card.cardset} {self.card.rarity_icon} {self.card.name}"
def save(self, *args, **kwargs):
self.trade_offer.update_rarity_fields()
super().save(*args, **kwargs)
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.cardnum} {self.card.cardset} {self.card.rarity_icon} {self.card.name}"
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]