466 lines
18 KiB
Python
466 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 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"])
|
|
|
|
@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.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)
|
|
|
|
@property
|
|
def qty_available(self):
|
|
return self.quantity - self.qty_accepted
|
|
|
|
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 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]
|