pkmntrade.club/trades/models.py

408 lines
16 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 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]
@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)
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")
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)
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")
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 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]