progress on conversion to tailwind
This commit is contained in:
parent
6a872124c6
commit
6e2843c60e
110 changed files with 4997 additions and 1691 deletions
247
trades/forms.py
247
trades/forms.py
|
|
@ -1,61 +1,15 @@
|
|||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import TradeOffer
|
||||
from friend_codes.models import FriendCode
|
||||
from .models import TradeOffer, TradeAcceptance
|
||||
from accounts.models import FriendCode
|
||||
from cards.models import Card
|
||||
from django.forms import ModelForm
|
||||
from trades.models import TradeOfferHaveCard, TradeOfferWantCard
|
||||
|
||||
class TradeOfferUpdateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TradeOffer
|
||||
# We now only edit the `state` field
|
||||
fields = ["state"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Expects additional keyword arguments:
|
||||
- friend_codes: a list of friend code objects for the current user.
|
||||
This initializer filters the available state choices based on:
|
||||
- The current state's allowed transition.
|
||||
- Which party (initiated_by or accepted_by) is acting.
|
||||
"""
|
||||
friend_codes = kwargs.pop("friend_codes")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
instance = self.instance
|
||||
allowed_state = None
|
||||
|
||||
# Define permitted transitions based on the current state and user role:
|
||||
if instance.state == TradeOffer.State.INITIATED:
|
||||
# Allow the accepted_by party to accept the trade.
|
||||
if instance.accepted_by in friend_codes:
|
||||
allowed_state = TradeOffer.State.ACCEPTED
|
||||
elif instance.state == TradeOffer.State.ACCEPTED:
|
||||
# Allow the initiated_by party to mark the trade as sent.
|
||||
if instance.initiated_by in friend_codes:
|
||||
allowed_state = TradeOffer.State.SENT
|
||||
elif instance.state == TradeOffer.State.SENT:
|
||||
# Allow the accepted_by party to mark the trade as received.
|
||||
if instance.accepted_by in friend_codes:
|
||||
allowed_state = TradeOffer.State.RECEIVED
|
||||
|
||||
if allowed_state:
|
||||
# Limit the `state` field's choices to only the permitted transition.
|
||||
label = dict(TradeOffer.State.choices)[allowed_state]
|
||||
self.fields["state"].choices = [(allowed_state, label)]
|
||||
else:
|
||||
# If no valid transition is available for this user, remove the field.
|
||||
self.fields.pop("state")
|
||||
|
||||
def clean_have_cards(self):
|
||||
have_cards = self.cleaned_data.get("have_cards")
|
||||
if have_cards:
|
||||
for card in have_cards.all():
|
||||
if card.rarity not in ALLOWED_RARITIES:
|
||||
# Raising a ValidationError here will cause this error message to be shown beneath the 'have_cards' field.
|
||||
raise ValidationError(
|
||||
f"The card '{card}' has an invalid rarity: {card.rarity}. Allowed rarities are: {', '.join(ALLOWED_RARITIES)}."
|
||||
)
|
||||
return have_cards
|
||||
class NoValidationMultipleChoiceField(forms.MultipleChoiceField):
|
||||
def validate(self, value):
|
||||
# Override the validation to skip checking against defined choices
|
||||
pass
|
||||
|
||||
class TradeOfferAcceptForm(forms.Form):
|
||||
friend_code = forms.ModelChoiceField(
|
||||
|
|
@ -68,3 +22,188 @@ class TradeOfferAcceptForm(forms.Form):
|
|||
friend_codes = kwargs.pop("friend_codes")
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["friend_code"].queryset = friend_codes
|
||||
|
||||
class TradeAcceptanceCreateForm(forms.ModelForm):
|
||||
"""
|
||||
Form for creating a TradeAcceptance.
|
||||
Expects the caller to pass:
|
||||
- trade_offer: the instance of TradeOffer this acceptance is for.
|
||||
- friend_codes: a queryset of FriendCode objects for the current user.
|
||||
It filters available requested and offered cards based on what's still available.
|
||||
"""
|
||||
class Meta:
|
||||
model = TradeAcceptance
|
||||
fields = ["accepted_by", "requested_card", "offered_card"]
|
||||
|
||||
def __init__(self, *args, trade_offer=None, friend_codes=None, **kwargs):
|
||||
if trade_offer is None:
|
||||
raise ValueError("trade_offer must be provided to filter choices.")
|
||||
super().__init__(*args, **kwargs)
|
||||
self.trade_offer = trade_offer
|
||||
|
||||
# Filter accepted_by to those friend codes that belong to the current user.
|
||||
if friend_codes is None:
|
||||
raise ValueError("friend_codes must be provided")
|
||||
self.fields["accepted_by"].queryset = friend_codes
|
||||
|
||||
active_states = [
|
||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||
TradeAcceptance.AcceptanceState.SENT,
|
||||
TradeAcceptance.AcceptanceState.RECEIVED,
|
||||
TradeAcceptance.AcceptanceState.COMPLETED,
|
||||
]
|
||||
|
||||
# Build available requested_card choices from the TradeOffer's "have" side.
|
||||
available_requested_ids = []
|
||||
for through_obj in trade_offer.trade_offer_have_cards.all():
|
||||
active_count = trade_offer.acceptances.filter(
|
||||
requested_card=through_obj.card,
|
||||
state__in=active_states
|
||||
).count()
|
||||
if active_count < through_obj.quantity:
|
||||
available_requested_ids.append(through_obj.card.id)
|
||||
self.fields["requested_card"].queryset = Card.objects.filter(id__in=available_requested_ids)
|
||||
|
||||
# Similarly, build available offered_card choices from the TradeOffer's "want" side.
|
||||
available_offered_ids = []
|
||||
for through_obj in trade_offer.trade_offer_want_cards.all():
|
||||
active_count = trade_offer.acceptances.filter(
|
||||
offered_card=through_obj.card,
|
||||
state__in=active_states
|
||||
).count()
|
||||
if active_count < through_obj.quantity:
|
||||
available_offered_ids.append(through_obj.card.id)
|
||||
self.fields["offered_card"].queryset = Card.objects.filter(id__in=available_offered_ids)
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Ensure the instance has its trade_offer set before model-level validation occurs.
|
||||
This prevents errors when the model clean() method attempts to access trade_offer.
|
||||
"""
|
||||
self.instance.trade_offer = self.trade_offer
|
||||
return super().clean()
|
||||
|
||||
class TradeAcceptanceUpdateForm(forms.ModelForm):
|
||||
"""
|
||||
Form for updating the state of an existing TradeAcceptance.
|
||||
Based on the current state and which party is acting (initiator vs. acceptor),
|
||||
this form limits available state transitions.
|
||||
"""
|
||||
class Meta:
|
||||
model = TradeAcceptance
|
||||
fields = ["state"]
|
||||
|
||||
def __init__(self, *args, friend_codes=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
instance = self.instance
|
||||
allowed_choices = []
|
||||
|
||||
# Allowed transitions for a TradeAcceptance:
|
||||
# - From ACCEPTED:
|
||||
# • If the initiator is acting, allow SENT and REJECTED_BY_INITIATOR.
|
||||
# • If the acceptor is acting, allow REJECTED_BY_ACCEPTOR.
|
||||
# - From SENT:
|
||||
# • If the acceptor is acting, allow RECEIVED and REJECTED_BY_ACCEPTOR.
|
||||
# • If the initiator is acting, allow REJECTED_BY_INITIATOR.
|
||||
# - From RECEIVED:
|
||||
# • If the initiator is acting, allow COMPLETED and REJECTED_BY_INITIATOR.
|
||||
# • If the acceptor is acting, allow REJECTED_BY_ACCEPTOR.
|
||||
if friend_codes is None:
|
||||
raise ValueError("friend_codes must be provided")
|
||||
if instance.state == TradeAcceptance.AcceptanceState.ACCEPTED:
|
||||
if instance.trade_offer.initiated_by in friend_codes:
|
||||
allowed_choices = [
|
||||
(TradeAcceptance.AcceptanceState.SENT, "Sent"),
|
||||
(TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, "Rejected by Initiator"),
|
||||
]
|
||||
elif instance.accepted_by in friend_codes:
|
||||
allowed_choices = [
|
||||
(TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, "Rejected by Acceptor"),
|
||||
]
|
||||
elif instance.state == TradeAcceptance.AcceptanceState.SENT:
|
||||
if instance.accepted_by in friend_codes:
|
||||
allowed_choices = [
|
||||
(TradeAcceptance.AcceptanceState.RECEIVED, "Received"),
|
||||
(TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, "Rejected by Acceptor"),
|
||||
]
|
||||
elif instance.trade_offer.initiated_by in friend_codes:
|
||||
allowed_choices = [
|
||||
(TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, "Rejected by Initiator"),
|
||||
]
|
||||
elif instance.state == TradeAcceptance.AcceptanceState.RECEIVED:
|
||||
if instance.trade_offer.initiated_by in friend_codes:
|
||||
allowed_choices = [
|
||||
(TradeAcceptance.AcceptanceState.COMPLETED, "Completed"),
|
||||
(TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, "Rejected by Initiator"),
|
||||
]
|
||||
elif instance.accepted_by in friend_codes:
|
||||
allowed_choices = [
|
||||
(TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, "Rejected by Acceptor"),
|
||||
]
|
||||
if allowed_choices:
|
||||
self.fields["state"].choices = allowed_choices
|
||||
else:
|
||||
self.fields.pop("state")
|
||||
|
||||
class TradeOfferCreateForm(ModelForm):
|
||||
# Override the default fields to capture quantity info in the format 'card_id:quantity'
|
||||
have_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True)
|
||||
want_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True)
|
||||
|
||||
class Meta:
|
||||
model = TradeOffer
|
||||
fields = ["want_cards", "have_cards", "initiated_by"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Populate choices from Card model
|
||||
cards = Card.objects.order_by("name", "rarity__pk")
|
||||
choices = [(str(card.pk), card.name) for card in cards]
|
||||
self.fields["have_cards"].choices = choices
|
||||
self.fields["want_cards"].choices = choices
|
||||
|
||||
def clean_have_cards(self):
|
||||
data = self.data.getlist("have_cards")
|
||||
parsed = {}
|
||||
for item in data:
|
||||
parts = item.split(':')
|
||||
card_id = parts[0]
|
||||
try:
|
||||
quantity = int(parts[1]) if len(parts) > 1 else 1
|
||||
except ValueError:
|
||||
raise forms.ValidationError(f"Invalid quantity provided in {item}")
|
||||
parsed[card_id] = parsed.get(card_id, 0) + quantity
|
||||
return parsed
|
||||
|
||||
def clean_want_cards(self):
|
||||
data = self.data.getlist("want_cards")
|
||||
parsed = {}
|
||||
for item in data:
|
||||
parts = item.split(':')
|
||||
card_id = parts[0]
|
||||
try:
|
||||
quantity = int(parts[1]) if len(parts) > 1 else 1
|
||||
except ValueError:
|
||||
raise forms.ValidationError(f"Invalid quantity provided in {item}")
|
||||
parsed[card_id] = parsed.get(card_id, 0) + quantity
|
||||
return parsed
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
if commit:
|
||||
instance.save()
|
||||
# Clear any existing through model entries in case of update
|
||||
TradeOfferHaveCard.objects.filter(trade_offer=instance).delete()
|
||||
TradeOfferWantCard.objects.filter(trade_offer=instance).delete()
|
||||
|
||||
# Create through entries for have_cards
|
||||
for card_id, quantity in self.cleaned_data["have_cards"].items():
|
||||
card = Card.objects.get(pk=card_id)
|
||||
TradeOfferHaveCard.objects.create(trade_offer=instance, card=card, quantity=quantity)
|
||||
|
||||
# Create through entries for want_cards
|
||||
for card_id, quantity in self.cleaned_data["want_cards"].items():
|
||||
card = Card.objects.get(pk=card_id)
|
||||
TradeOfferWantCard.objects.create(trade_offer=instance, card=card, quantity=quantity)
|
||||
|
||||
return instance
|
||||
|
|
|
|||
75
trades/migrations/0001_initial.py
Normal file
75
trades/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Generated by Django 5.1.2 on 2025-03-07 01:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
('cards', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TradeOffer',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('manually_closed', models.BooleanField(default=False)),
|
||||
('hash', models.CharField(editable=False, max_length=8)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_trade_offers', to='accounts.friendcode')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TradeAcceptance',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('state', models.CharField(choices=[('ACCEPTED', 'Accepted'), ('SENT', 'Sent'), ('RECEIVED', 'Received'), ('COMPLETED', 'Completed'), ('REJECTED_BY_INITIATOR', 'Rejected by Initiator'), ('REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor')], default='ACCEPTED', max_length=25)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('accepted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='trade_acceptances', to='accounts.friendcode')),
|
||||
('offered_card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accepted_offered', to='cards.card')),
|
||||
('requested_card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accepted_requested', to='cards.card')),
|
||||
('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='acceptances', to='trades.tradeoffer')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TradeOfferHaveCard',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.PositiveIntegerField(default=1)),
|
||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cards.card')),
|
||||
('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trade_offer_have_cards', to='trades.tradeoffer')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('trade_offer', 'card')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tradeoffer',
|
||||
name='have_cards',
|
||||
field=models.ManyToManyField(related_name='trade_offers_have', through='trades.TradeOfferHaveCard', to='cards.card'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TradeOfferWantCard',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.PositiveIntegerField(default=1)),
|
||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cards.card')),
|
||||
('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trade_offer_want_cards', to='trades.tradeoffer')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('trade_offer', 'card')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tradeoffer',
|
||||
name='want_cards',
|
||||
field=models.ManyToManyField(related_name='trade_offers_want', through='trades.TradeOfferWantCard', to='cards.card'),
|
||||
),
|
||||
]
|
||||
297
trades/models.py
297
trades/models.py
|
|
@ -1,72 +1,255 @@
|
|||
from django.db import models
|
||||
import hashlib # <-- import hashlib for computing md5
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
import hashlib
|
||||
from cards.models import Card
|
||||
from friend_codes.models import FriendCode
|
||||
from accounts.models import FriendCode
|
||||
|
||||
class TradeOffer(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
hash = models.CharField(max_length=8, editable=False)
|
||||
initiated_by = models.ForeignKey("friend_codes.FriendCode", on_delete=models.PROTECT, related_name='initiated_by')
|
||||
accepted_by = models.ForeignKey("friend_codes.FriendCode", on_delete=models.PROTECT, null=True, blank=True, related_name='accepted_by')
|
||||
want_cards = models.ManyToManyField("cards.Card", related_name='trade_offers_want')
|
||||
have_cards = models.ManyToManyField("cards.Card", related_name='trade_offers_have')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
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)
|
||||
|
||||
class State(models.TextChoices):
|
||||
INITIATED = 'INITIATED', 'Initiated'
|
||||
ACCEPTED = 'ACCEPTED', 'Accepted'
|
||||
SENT = 'SENT', 'Sent'
|
||||
RECEIVED = 'RECEIVED', 'Received'
|
||||
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}"
|
||||
|
||||
state = models.CharField(
|
||||
max_length=10,
|
||||
choices=State.choices,
|
||||
default=State.INITIATED,
|
||||
)
|
||||
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"])
|
||||
|
||||
def __str__(self):
|
||||
return f"Want: {', '.join([x.name for x in self.want_cards.all()])} -> Have: {', '.join([x.name for x in self.have_cards.all()])}"
|
||||
@property
|
||||
def is_closed(self):
|
||||
if self.manually_closed:
|
||||
return True
|
||||
|
||||
def update_state(self, new_state):
|
||||
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):
|
||||
"""
|
||||
Explicitly update the trade state to new_state if allowed.
|
||||
|
||||
Allowed transitions:
|
||||
- INITIATED -> ACCEPTED
|
||||
- ACCEPTED -> SENT
|
||||
- SENT -> RECEIVED
|
||||
|
||||
Raises:
|
||||
ValueError: If the new_state is not allowed.
|
||||
Through model for TradeOffer.have_cards.
|
||||
Represents the card the initiator is offering along with the quantity available.
|
||||
"""
|
||||
allowed_transitions = {
|
||||
self.State.INITIATED: self.State.ACCEPTED,
|
||||
self.State.ACCEPTED: self.State.SENT,
|
||||
self.State.SENT: self.State.RECEIVED
|
||||
}
|
||||
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)
|
||||
|
||||
# Check that new_state is one of the defined State choices
|
||||
if new_state not in [choice[0] for choice in self.State.choices]:
|
||||
raise ValueError(f"'{new_state}' is not a valid state.")
|
||||
def __str__(self):
|
||||
return f"{self.card.name} x{self.quantity} (Have side for offer {self.trade_offer.hash})"
|
||||
|
||||
# If the current state is already final, no further transition is allowed.
|
||||
if self.state not in allowed_transitions:
|
||||
raise ValueError(f"No transitions allowed from the final state '{self.state}'.")
|
||||
class Meta:
|
||||
unique_together = ("trade_offer", "card")
|
||||
|
||||
# Verify that the desired new_state is the valid transition for the current state.
|
||||
if allowed_transitions[self.state] != new_state:
|
||||
raise ValueError(f"Transition from {self.state} to {new_state} is not allowed.")
|
||||
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)
|
||||
|
||||
self.state = new_state
|
||||
# Save all changes so that any in-memory modifications (like accepted_by) are persisted.
|
||||
self.save() # Changed from self.save(update_fields=["state"])
|
||||
def __str__(self):
|
||||
return f"{self.card.name} x{self.quantity} (Want side for offer {self.trade_offer.hash})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Determine if the object is being created (i.e. it doesn't yet have a pk)
|
||||
is_new = self.pk is None
|
||||
super().save(*args, **kwargs)
|
||||
# Once the object has a pk, compute and save the hash if it hasn't been set yet.
|
||||
if is_new and not self.hash:
|
||||
self.hash = hashlib.md5(str(self.id).encode('utf-8')).hexdigest()[:8]
|
||||
super().save(update_fields=["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
|
||||
|
|
|
|||
|
|
@ -6,11 +6,8 @@ from cards.models import Card
|
|||
|
||||
def check_trade_offer_rarity(instance):
|
||||
combined_cards = list(instance.have_cards.all()) + list(instance.want_cards.all())
|
||||
# Map rarities 6 (Super Rare) and 7 (Special Art Rare) to a single value (here, 6)
|
||||
rarities = {
|
||||
card.rarity_id if card.rarity_id not in (6, 7) else 6
|
||||
for card in combined_cards
|
||||
}
|
||||
# Use the normalized rarity from each card
|
||||
rarities = {card.normalized_rarity for card in combined_cards}
|
||||
if len(rarities) > 1:
|
||||
raise ValidationError("All cards in a trade offer must have the same rarity.")
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,53 @@ from django import template
|
|||
|
||||
register = template.Library()
|
||||
|
||||
@register.inclusion_tag('includes/trade_offer.html')
|
||||
def render_trade_offer(offer):
|
||||
@register.inclusion_tag('templatetags/trade_offer.html', takes_context=True)
|
||||
def render_trade_offer(context, offer):
|
||||
"""
|
||||
Renders a trade offer in the desired format.
|
||||
Renders a trade offer including detailed trade acceptance information.
|
||||
Groups acceptances for each card on both the have and want sides.
|
||||
"""
|
||||
request = context.get('request')
|
||||
current_friend_code = (
|
||||
getattr(request.user, 'friendcode', None)
|
||||
if request and request.user.is_authenticated
|
||||
else None
|
||||
)
|
||||
|
||||
# Get all acceptances with optimized queries.
|
||||
acceptances = offer.acceptances.all().select_related(
|
||||
'accepted_by', 'requested_card', 'offered_card'
|
||||
)
|
||||
|
||||
# Build grouping for the have side.
|
||||
have_acceptances_data = []
|
||||
for have in offer.trade_offer_have_cards.all():
|
||||
group = {
|
||||
'card': have.card,
|
||||
'quantity': have.quantity,
|
||||
# Filter acceptances where the requested_card matches the have card.
|
||||
'acceptances': [
|
||||
acc for acc in acceptances if acc.requested_card_id == have.card.id
|
||||
],
|
||||
}
|
||||
have_acceptances_data.append(group)
|
||||
|
||||
# Build grouping for the want side.
|
||||
want_acceptances_data = []
|
||||
for want in offer.trade_offer_want_cards.all():
|
||||
group = {
|
||||
'card': want.card,
|
||||
'quantity': want.quantity,
|
||||
# Filter acceptances where the offered_card matches the want card.
|
||||
'acceptances': [
|
||||
acc for acc in acceptances if acc.offered_card_id == want.card.id
|
||||
],
|
||||
}
|
||||
want_acceptances_data.append(group)
|
||||
|
||||
return {
|
||||
'offer': offer
|
||||
}
|
||||
'offer': offer,
|
||||
'have_acceptances_data': have_acceptances_data,
|
||||
'want_acceptances_data': want_acceptances_data,
|
||||
'current_friend_code': current_friend_code,
|
||||
}
|
||||
|
|
@ -3,7 +3,9 @@ from django.urls import path
|
|||
from .views import (
|
||||
TradeOfferCreateView,
|
||||
TradeOfferListView,
|
||||
TradeOfferUpdateView,
|
||||
TradeOfferDetailView,
|
||||
TradeAcceptanceCreateView,
|
||||
TradeAcceptanceUpdateView,
|
||||
TradeOfferDeleteView,
|
||||
TradeOfferSearchView,
|
||||
)
|
||||
|
|
@ -12,6 +14,8 @@ urlpatterns = [
|
|||
path("create/", TradeOfferCreateView.as_view(), name="trade_offer_create"),
|
||||
path("", TradeOfferListView.as_view(), name="trade_offer_list"),
|
||||
path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"),
|
||||
path("<int:pk>/", TradeOfferUpdateView.as_view(), name="trade_offer_update"),
|
||||
path("<int:pk>/", TradeOfferDetailView.as_view(), name="trade_offer_detail"),
|
||||
path("delete/<int:pk>/", TradeOfferDeleteView.as_view(), name="trade_offer_delete"),
|
||||
path("offer/<int:offer_pk>/accept/", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"),
|
||||
path("acceptance/<int:pk>/", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"),
|
||||
]
|
||||
|
|
|
|||
397
trades/views.py
397
trades/views.py
|
|
@ -1,187 +1,195 @@
|
|||
from django.views.generic import TemplateView, DeleteView, CreateView, ListView, DetailView, FormView
|
||||
from django.views.generic import TemplateView, DeleteView, CreateView, ListView, DetailView, UpdateView, FormView
|
||||
from django.urls import reverse_lazy
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.views.generic.edit import FormMixin
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.core.paginator import Paginator
|
||||
from django.contrib import messages
|
||||
|
||||
from .models import TradeOffer
|
||||
from .forms import TradeOfferUpdateForm, TradeOfferAcceptForm
|
||||
from .models import TradeOffer, TradeAcceptance
|
||||
from .forms import (TradeOfferAcceptForm,
|
||||
TradeAcceptanceCreateForm, TradeAcceptanceUpdateForm, TradeOfferCreateForm)
|
||||
from cards.models import Card
|
||||
|
||||
class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
||||
model = TradeOffer
|
||||
form_class = TradeOfferCreateForm
|
||||
template_name = "trades/trade_offer_create.html"
|
||||
success_url = reverse_lazy("trade_offer_list")
|
||||
fields = ["want_cards", "have_cards", "initiated_by"]
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
# Restrict the 'initiated_by' choices to friend codes owned by the logged-in user.
|
||||
form.fields["initiated_by"].queryset = self.request.user.friend_codes.all()
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
# Standardize parameter names: use "have_cards" and "want_cards"
|
||||
initial["have_cards"] = self.request.GET.getlist("have_cards")
|
||||
initial["want_cards"] = self.request.GET.getlist("want_cards")
|
||||
# If the user has only one friend code, set it as the default.
|
||||
if self.request.user.friend_codes.count() == 1:
|
||||
initial["initiated_by"] = self.request.user.friend_codes.first().pk
|
||||
return initial
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
friend_codes = self.request.user.friend_codes.all()
|
||||
if "initiated_by" in self.request.GET:
|
||||
try:
|
||||
selected_friend_code = friend_codes.get(pk=self.request.GET.get("initiated_by"))
|
||||
except friend_codes.model.DoesNotExist:
|
||||
selected_friend_code = self.request.user.default_friend_code or friend_codes.first()
|
||||
else:
|
||||
selected_friend_code = self.request.user.default_friend_code or friend_codes.first()
|
||||
context["friend_codes"] = friend_codes
|
||||
context["selected_friend_code"] = selected_friend_code
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
# Save the object without committing m2m fields immediately.
|
||||
self.object = form.save(commit=False)
|
||||
self.object.save()
|
||||
try:
|
||||
# This call will trigger the m2m signals and may raise a ValidationError.
|
||||
form.save_m2m()
|
||||
except ValidationError as e:
|
||||
# Attach the error message to the "have_cards" field (or as a non-field error)
|
||||
form.add_error("have_cards", e.messages[0])
|
||||
return self.form_invalid(form)
|
||||
# Double-check that the chosen friend code is owned by the current user.
|
||||
friend_codes = self.request.user.friend_codes.all()
|
||||
if form.cleaned_data.get("initiated_by") not in friend_codes:
|
||||
raise PermissionDenied("You cannot initiate trade offers for friend codes that do not belong to you.")
|
||||
self.object = form.save()
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
class TradeOfferListView(LoginRequiredMixin, ListView):
|
||||
model = TradeOffer
|
||||
model = TradeOffer # Fallback model; our context data will hold separate querysets.
|
||||
template_name = "trades/trade_offer_list.html"
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset().prefetch_related("have_cards", "want_cards").select_related("initiated_by", "accepted_by")
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
request = self.request
|
||||
|
||||
show_completed = request.GET.get("show_completed", "").lower() in ["true", "1"]
|
||||
my_trades = request.GET.get("my_trades", "").lower() in ["true", "1"]
|
||||
# Determine if the user wants to see completed (closed) items.
|
||||
show_completed = request.GET.get("show_completed", "false").lower() == "true"
|
||||
context["show_completed"] = show_completed
|
||||
|
||||
now = timezone.now()
|
||||
seven_days_ago = now - timezone.timedelta(days=7)
|
||||
# Get all friend codes for the current user.
|
||||
friend_codes = request.user.friend_codes.all()
|
||||
friend_code_param = request.GET.get("friend_code")
|
||||
if friend_code_param:
|
||||
try:
|
||||
selected_friend_code = friend_codes.get(pk=friend_code_param)
|
||||
except friend_codes.model.DoesNotExist:
|
||||
selected_friend_code = request.user.default_friend_code or friend_codes.first()
|
||||
else:
|
||||
selected_friend_code = request.user.default_friend_code or friend_codes.first()
|
||||
|
||||
if not selected_friend_code:
|
||||
raise PermissionDenied("You do not have an active friend code associated with your account.")
|
||||
|
||||
context["selected_friend_code"] = selected_friend_code
|
||||
context["friend_codes"] = friend_codes
|
||||
|
||||
# ----- My Trade Offers -----
|
||||
if show_completed:
|
||||
my_trade_offers = TradeOffer.objects.filter(initiated_by=selected_friend_code).order_by("-updated_at")
|
||||
my_trade_offers = [offer for offer in my_trade_offers if offer.is_closed]
|
||||
else:
|
||||
my_trade_offers = TradeOffer.objects.filter(initiated_by=selected_friend_code).order_by("-updated_at")
|
||||
my_trade_offers = [offer for offer in my_trade_offers if not offer.is_closed]
|
||||
|
||||
# ----- Trade Acceptances involving the user -----
|
||||
terminal_states = [
|
||||
TradeAcceptance.AcceptanceState.COMPLETED,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
]
|
||||
involved_acceptances = TradeAcceptance.objects.filter(
|
||||
Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code)
|
||||
).order_by("-updated_at")
|
||||
|
||||
if show_completed:
|
||||
qs = qs.filter(Q(state=TradeOffer.State.RECEIVED))
|
||||
involved_acceptances = involved_acceptances.filter(state__in=terminal_states)
|
||||
else:
|
||||
qs = qs.filter(updated_at__gte=seven_days_ago).exclude(state=TradeOffer.State.RECEIVED)
|
||||
involved_acceptances = involved_acceptances.exclude(state__in=terminal_states)
|
||||
|
||||
if my_trades:
|
||||
friend_codes = self.request.user.friend_codes.all()
|
||||
qs = qs.filter(Q(initiated_by__in=friend_codes) | Q(accepted_by__in=friend_codes))
|
||||
|
||||
return qs.order_by("-updated_at")
|
||||
# ----- Split Acceptances into "Waiting for Your Response" and "Other" -----
|
||||
waiting_acceptances = involved_acceptances.filter(
|
||||
Q(trade_offer__initiated_by=selected_friend_code, state__in=[
|
||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||
TradeAcceptance.AcceptanceState.RECEIVED,
|
||||
]) |
|
||||
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.SENT)
|
||||
)
|
||||
other_trade_acceptances = involved_acceptances.exclude(pk__in=waiting_acceptances.values("pk"))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["show_completed"] = self.request.GET.get("show_completed", "").lower() in ["true", "1"]
|
||||
context["my_trades"] = self.request.GET.get("my_trades", "").lower() in ["true", "1"]
|
||||
# ----- Paginate Each Section Separately -----
|
||||
offers_page = request.GET.get("offers_page")
|
||||
waiting_page = request.GET.get("waiting_page")
|
||||
other_page = request.GET.get("other_page")
|
||||
|
||||
offers_paginator = Paginator(my_trade_offers, 10)
|
||||
waiting_paginator = Paginator(waiting_acceptances, 10)
|
||||
other_paginator = Paginator(other_trade_acceptances, 10)
|
||||
|
||||
context["my_trade_offers_paginated"] = offers_paginator.get_page(offers_page)
|
||||
context["trade_acceptances_waiting_paginated"] = waiting_paginator.get_page(waiting_page)
|
||||
context["other_trade_acceptances_paginated"] = other_paginator.get_page(other_page)
|
||||
return context
|
||||
|
||||
class TradeOfferUpdateView(LoginRequiredMixin, FormMixin, DetailView):
|
||||
"""
|
||||
Merged view that displays trade offer details and renders a form used for either:
|
||||
- Accepting an offer (if in INITIATED state and not initiated by the current user), or
|
||||
- Performing an allowed state transition via the update form.
|
||||
"""
|
||||
model = TradeOffer
|
||||
template_name = "trades/trade_offer_update.html"
|
||||
success_url = reverse_lazy("trade_offer_list")
|
||||
|
||||
def get_user_friend_codes(self):
|
||||
return self.request.user.friend_codes.all()
|
||||
|
||||
def get_form_class(self):
|
||||
trade_offer = self.get_object()
|
||||
user_friend_codes = self.get_user_friend_codes()
|
||||
if trade_offer.state == trade_offer.State.INITIATED and trade_offer.initiated_by not in user_friend_codes:
|
||||
return TradeOfferAcceptForm
|
||||
return TradeOfferUpdateForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["friend_codes"] = self.get_user_friend_codes()
|
||||
if self.get_form_class() == TradeOfferUpdateForm:
|
||||
kwargs["instance"] = self.get_object()
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
form_class = self.get_form_class()
|
||||
if "form" not in context:
|
||||
context["form"] = self.get_form(form_class)
|
||||
context["action"] = "accept" if form_class == TradeOfferAcceptForm else "update"
|
||||
|
||||
trade_offer = self.object
|
||||
user_friend_codes = self.get_user_friend_codes()
|
||||
seven_days_ago = timezone.now() - timezone.timedelta(days=7)
|
||||
|
||||
can_delete = False
|
||||
if trade_offer.initiated_by in user_friend_codes:
|
||||
if trade_offer.state == trade_offer.State.INITIATED:
|
||||
can_delete = True
|
||||
elif trade_offer.state == trade_offer.State.SENT and trade_offer.updated_at < seven_days_ago:
|
||||
can_delete = True
|
||||
elif trade_offer.accepted_by in user_friend_codes:
|
||||
if trade_offer.state in [trade_offer.State.ACCEPTED, trade_offer.State.RECEIVED]:
|
||||
if trade_offer.updated_at < seven_days_ago:
|
||||
can_delete = True
|
||||
|
||||
context["can_delete"] = can_delete
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
user_friend_codes = self.get_user_friend_codes()
|
||||
form_class = self.get_form_class()
|
||||
form = self.get_form(form_class)
|
||||
|
||||
if form_class == TradeOfferAcceptForm:
|
||||
if not (self.object.state == self.object.State.INITIATED and
|
||||
self.object.initiated_by not in user_friend_codes):
|
||||
raise PermissionDenied("You are not allowed to accept this trade offer.")
|
||||
|
||||
if form.is_valid():
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
|
||||
def form_valid(self, form):
|
||||
"""
|
||||
Save the instance and its many-to-many fields in a try/except block to catch
|
||||
ValidationError raised by the m2m_changed signal (or a custom validation method).
|
||||
"""
|
||||
trade_offer = self.get_object()
|
||||
# For example, if you want to perform a pre-save validation of card rarities,
|
||||
# you might call a model method (that you define) like:
|
||||
try:
|
||||
trade_offer.validate_card_rarities()
|
||||
except ValueError as e:
|
||||
form.add_error("have_cards", str(e))
|
||||
return self.form_invalid(form)
|
||||
|
||||
# For the m2m part, manually save to catch errors from the signal:
|
||||
self.object = form.save(commit=False)
|
||||
# Process state change or friend code acceptance:
|
||||
if isinstance(form, TradeOfferAcceptForm):
|
||||
chosen_friend_code = form.cleaned_data["friend_code"]
|
||||
trade_offer.accepted_by = chosen_friend_code
|
||||
try:
|
||||
trade_offer.update_state(TradeOffer.State.ACCEPTED)
|
||||
except ValueError as e:
|
||||
# Attach as non-field error (or on a specific field if you prefer)
|
||||
form.add_error(None, str(e))
|
||||
return self.form_invalid(form)
|
||||
else:
|
||||
new_state = form.cleaned_data["state"]
|
||||
try:
|
||||
trade_offer.update_state(new_state)
|
||||
except ValueError as e:
|
||||
form.add_error("state", str(e))
|
||||
return self.form_invalid(form)
|
||||
|
||||
try:
|
||||
# Save instance and its m2m fields; any ValidationError raised here (e.g.,
|
||||
# from the m2m_changed signals) will be caught.
|
||||
self.object.save() # Save the TradeOffer instance.
|
||||
form.save_m2m() # This call triggers the m2m_changed signals.
|
||||
except ValidationError as e:
|
||||
# Here we attach the signal error (from card rarities) to the form so that
|
||||
# the user can see it. You can attach it to a specific field or as a non-field error.
|
||||
form.add_error("have_cards", e.messages[0])
|
||||
return self.form_invalid(form)
|
||||
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
|
||||
model = TradeOffer
|
||||
success_url = reverse_lazy("trade_offer_list")
|
||||
template_name = "trades/trade_offer_delete.html"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
trade_offer = self.get_object()
|
||||
if trade_offer.initiated_by not in request.user.friend_codes.all():
|
||||
raise PermissionDenied("You are not authorized to delete or close this trade offer.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
trade_offer = self.get_object()
|
||||
terminal_states = [
|
||||
TradeAcceptance.AcceptanceState.COMPLETED,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
]
|
||||
active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states)
|
||||
if trade_offer.acceptances.count() == 0:
|
||||
context["action"] = "delete"
|
||||
elif trade_offer.acceptances.count() > 0 and not active_acceptances.exists():
|
||||
context["action"] = "close"
|
||||
else:
|
||||
context["action"] = None
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
trade_offer = self.get_object()
|
||||
terminal_states = [
|
||||
TradeAcceptance.AcceptanceState.COMPLETED,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
]
|
||||
active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states)
|
||||
|
||||
if active_acceptances.exists():
|
||||
messages.error(request, "Cannot delete or close this trade offer because there are active acceptances.")
|
||||
context = self.get_context_data(object=trade_offer)
|
||||
return self.render_to_response(context)
|
||||
else:
|
||||
if trade_offer.acceptances.count() > 0:
|
||||
# There are terminal acceptances: mark the offer as closed.
|
||||
trade_offer.manually_closed = True
|
||||
trade_offer.save(update_fields=["manually_closed"])
|
||||
messages.success(request, "Trade offer has been marked as closed.")
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
else:
|
||||
# No acceptances: proceed with deletion.
|
||||
messages.success(request, "Trade offer has been deleted.")
|
||||
return super().delete(request, *args, **kwargs)
|
||||
|
||||
class TradeOfferSearchView(LoginRequiredMixin, ListView):
|
||||
model = TradeOffer
|
||||
template_name = "trades/trade_offer_search.html"
|
||||
|
|
@ -222,3 +230,94 @@ class TradeOfferSearchView(LoginRequiredMixin, ListView):
|
|||
context["available_cards"] = Card.objects.order_by("name", "rarity__pk").select_related("rarity", "cardset")
|
||||
return context
|
||||
|
||||
class TradeOfferDetailView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
Displays the details of a TradeOffer along with its active acceptances.
|
||||
If the offer is still open and the current user is not its initiator,
|
||||
an acceptance form is provided to create a new acceptance.
|
||||
"""
|
||||
model = TradeOffer
|
||||
template_name = "trades/trade_offer_detail.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
trade_offer = self.get_object()
|
||||
active_states = [
|
||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||
TradeAcceptance.AcceptanceState.SENT,
|
||||
TradeAcceptance.AcceptanceState.RECEIVED,
|
||||
TradeAcceptance.AcceptanceState.COMPLETED,
|
||||
]
|
||||
context["acceptances"] = trade_offer.acceptances.filter(state__in=active_states)
|
||||
user_friend_codes = self.request.user.friend_codes.all()
|
||||
|
||||
# Add context flag and deletion URL if the current user is the initiator
|
||||
if trade_offer.initiated_by in user_friend_codes:
|
||||
context["is_initiator"] = True
|
||||
context["delete_close_url"] = reverse_lazy("trade_offer_delete", kwargs={"pk": trade_offer.pk})
|
||||
else:
|
||||
context["is_initiator"] = False
|
||||
|
||||
# If the current user is not the initiator and the offer is open, allow a new acceptance.
|
||||
if trade_offer.initiated_by not in user_friend_codes and not trade_offer.is_closed:
|
||||
context["acceptance_form"] = TradeAcceptanceCreateForm(
|
||||
trade_offer=trade_offer, friend_codes=user_friend_codes
|
||||
)
|
||||
return context
|
||||
|
||||
class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
View to create a new TradeAcceptance.
|
||||
The URL should provide 'offer_pk' so that the proper TradeOffer can be identified.
|
||||
"""
|
||||
model = TradeAcceptance
|
||||
form_class = TradeAcceptanceCreateForm
|
||||
template_name = "trades/trade_acceptance_create.html"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.trade_offer = get_object_or_404(TradeOffer, pk=kwargs.get("offer_pk"))
|
||||
# Disallow acceptance if the current user is the offer initiator or if the offer is closed.
|
||||
if self.trade_offer.initiated_by in request.user.friend_codes.all() or self.trade_offer.is_closed:
|
||||
raise PermissionDenied("You cannot accept this trade offer.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["trade_offer"] = self.trade_offer
|
||||
kwargs["friend_codes"] = self.request.user.friend_codes.all()
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.trade_offer = self.trade_offer
|
||||
self.object = form.save()
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("trade_offer_detail", kwargs={"pk": self.trade_offer.pk})
|
||||
|
||||
class TradeAcceptanceUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
View to update the state of an existing TradeAcceptance.
|
||||
The allowed state transitions are provided via the form.
|
||||
"""
|
||||
model = TradeAcceptance
|
||||
form_class = TradeAcceptanceUpdateForm
|
||||
template_name = "trades/trade_acceptance_update.html"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["friend_codes"] = self.request.user.friend_codes.all()
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
try:
|
||||
# Use the model's update_state logic.
|
||||
form.instance.update_state(form.cleaned_data["state"])
|
||||
except ValueError as e:
|
||||
form.add_error("state", str(e))
|
||||
return self.form_invalid(form)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("trade_offer_detail", kwargs={"pk": self.object.trade_offer.pk})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue