finished conversion to tailwind
This commit is contained in:
parent
6e2843c60e
commit
d62956d465
50 changed files with 2490 additions and 1273 deletions
|
|
@ -5,5 +5,6 @@ class TradesConfig(AppConfig):
|
|||
name = "trades"
|
||||
|
||||
def ready(self):
|
||||
# This import registers the signal handlers defined in trades/signals.py.
|
||||
# This import registers the signal handlers defined in trades/signals.py,
|
||||
# ensuring that denormalized field updates occur whenever related objects change.
|
||||
import trades.signals
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
|
|||
raise ValueError("friend_codes must be provided")
|
||||
self.fields["accepted_by"].queryset = friend_codes
|
||||
|
||||
# Update active_states to include only states that mean the acceptance is still "open".
|
||||
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.
|
||||
|
|
@ -83,67 +83,24 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
|
|||
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"]
|
||||
class ButtonRadioSelect(forms.RadioSelect):
|
||||
template_name = "widgets/button_radio_select.html"
|
||||
|
||||
def __init__(self, *args, friend_codes=None, **kwargs):
|
||||
class TradeAcceptanceTransitionForm(forms.Form):
|
||||
state = forms.ChoiceField(widget=forms.HiddenInput())
|
||||
|
||||
def __init__(self, *args, instance=None, user=None, **kwargs):
|
||||
"""
|
||||
Initializes the form with allowed transitions from the provided instance.
|
||||
:param instance: A TradeAcceptance instance.
|
||||
"""
|
||||
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")
|
||||
if instance is None:
|
||||
raise ValueError("A TradeAcceptance instance must be provided")
|
||||
self.instance = instance
|
||||
self.user = user
|
||||
|
||||
self.fields["state"].choices = instance.get_allowed_state_transitions(user)
|
||||
|
||||
class TradeOfferCreateForm(ModelForm):
|
||||
# Override the default fields to capture quantity info in the format 'card_id:quantity'
|
||||
|
|
@ -166,10 +123,14 @@ class TradeOfferCreateForm(ModelForm):
|
|||
data = self.data.getlist("have_cards")
|
||||
parsed = {}
|
||||
for item in data:
|
||||
if ':' not in item:
|
||||
# Ignore any input without a colon.
|
||||
continue
|
||||
parts = item.split(':')
|
||||
card_id = parts[0]
|
||||
try:
|
||||
quantity = int(parts[1]) if len(parts) > 1 else 1
|
||||
# Only parse quantity when a colon is present.
|
||||
quantity = int(parts[1])
|
||||
except ValueError:
|
||||
raise forms.ValidationError(f"Invalid quantity provided in {item}")
|
||||
parsed[card_id] = parsed.get(card_id, 0) + quantity
|
||||
|
|
@ -179,10 +140,12 @@ class TradeOfferCreateForm(ModelForm):
|
|||
data = self.data.getlist("want_cards")
|
||||
parsed = {}
|
||||
for item in data:
|
||||
if ':' not in item:
|
||||
continue
|
||||
parts = item.split(':')
|
||||
card_id = parts[0]
|
||||
try:
|
||||
quantity = int(parts[1]) if len(parts) > 1 else 1
|
||||
quantity = int(parts[1])
|
||||
except ValueError:
|
||||
raise forms.ValidationError(f"Invalid quantity provided in {item}")
|
||||
parsed[card_id] = parsed.get(card_id, 0) + quantity
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated by Django 5.1.2 on 2025-03-07 01:04
|
||||
# Generated by Django 5.1.2 on 2025-03-09 05:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
|
@ -18,10 +18,14 @@ class Migration(migrations.Migration):
|
|||
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)),
|
||||
('manually_closed', models.BooleanField(db_index=True, default=False)),
|
||||
('hash', models.CharField(editable=False, max_length=9)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('total_have_quantity', models.PositiveIntegerField(default=0, editable=False)),
|
||||
('total_want_quantity', models.PositiveIntegerField(default=0, editable=False)),
|
||||
('total_have_accepted', models.PositiveIntegerField(default=0, editable=False)),
|
||||
('total_want_accepted', models.PositiveIntegerField(default=0, editable=False)),
|
||||
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_trade_offers', to='accounts.friendcode')),
|
||||
],
|
||||
),
|
||||
|
|
@ -29,7 +33,8 @@ class Migration(migrations.Migration):
|
|||
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)),
|
||||
('state', models.CharField(choices=[('ACCEPTED', 'Accepted'), ('SENT', 'Sent'), ('RECEIVED', 'Received'), ('THANKED_BY_INITIATOR', 'Thanked by Initiator'), ('THANKED_BY_ACCEPTOR', 'Thanked by Acceptor'), ('THANKED_BY_BOTH', 'Thanked by Both'), ('REJECTED_BY_INITIATOR', 'Rejected by Initiator'), ('REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor')], db_index=True, default='ACCEPTED', max_length=25)),
|
||||
('hash', models.CharField(blank=True, editable=False, max_length=9)),
|
||||
('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')),
|
||||
|
|
@ -72,4 +77,8 @@ class Migration(migrations.Migration):
|
|||
name='want_cards',
|
||||
field=models.ManyToManyField(related_name='trade_offers_want', through='trades.TradeOfferWantCard', to='cards.card'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='tradeoffer',
|
||||
index=models.Index(fields=['manually_closed'], name='trades_trad_manuall_b3b74c_idx'),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
334
trades/models.py
334
trades/models.py
|
|
@ -7,8 +7,8 @@ from accounts.models import FriendCode
|
|||
|
||||
class TradeOffer(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
manually_closed = models.BooleanField(default=False)
|
||||
hash = models.CharField(max_length=8, editable=False)
|
||||
manually_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,
|
||||
|
|
@ -28,6 +28,12 @@ class TradeOffer(models.Model):
|
|||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# New denormalized fields for aggregated counts
|
||||
total_have_quantity = models.PositiveIntegerField(default=0, editable=False)
|
||||
total_want_quantity = models.PositiveIntegerField(default=0, editable=False)
|
||||
total_have_accepted = models.PositiveIntegerField(default=0, editable=False)
|
||||
total_want_accepted = models.PositiveIntegerField(default=0, editable=False)
|
||||
|
||||
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()])
|
||||
|
|
@ -37,44 +43,69 @@ class TradeOffer(models.Model):
|
|||
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]
|
||||
self.hash = hashlib.md5((str(self.id) + "z").encode("utf-8")).hexdigest()[:8] + "z"
|
||||
super().save(update_fields=["hash"])
|
||||
|
||||
def update_aggregates(self):
|
||||
"""
|
||||
Recalculate and update aggregated fields from related have/want cards and acceptances.
|
||||
"""
|
||||
from django.db.models import Sum
|
||||
from trades.models import TradeAcceptance
|
||||
|
||||
# Calculate total quantities from through models
|
||||
have_agg = self.trade_offer_have_cards.aggregate(total=Sum("quantity"))
|
||||
want_agg = self.trade_offer_want_cards.aggregate(total=Sum("quantity"))
|
||||
self.total_have_quantity = have_agg["total"] or 0
|
||||
self.total_want_quantity = want_agg["total"] or 0
|
||||
|
||||
# Define acceptance states that count as active.
|
||||
active_states = [
|
||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||
TradeAcceptance.AcceptanceState.SENT,
|
||||
TradeAcceptance.AcceptanceState.RECEIVED,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
||||
]
|
||||
|
||||
# Compute accepted counts based on matching card IDs.
|
||||
have_card_ids = list(self.trade_offer_have_cards.values_list("card_id", flat=True))
|
||||
want_card_ids = list(self.trade_offer_want_cards.values_list("card_id", flat=True))
|
||||
|
||||
self.total_have_accepted = TradeAcceptance.objects.filter(
|
||||
trade_offer=self,
|
||||
state__in=active_states,
|
||||
requested_card_id__in=have_card_ids,
|
||||
).count()
|
||||
|
||||
self.total_want_accepted = TradeAcceptance.objects.filter(
|
||||
trade_offer=self,
|
||||
state__in=active_states,
|
||||
offered_card_id__in=want_card_ids,
|
||||
).count()
|
||||
|
||||
# Save updated aggregate values so they are denormalized in the database.
|
||||
self.save(update_fields=[
|
||||
"total_have_quantity",
|
||||
"total_want_quantity",
|
||||
"total_have_accepted",
|
||||
"total_want_accepted",
|
||||
])
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
if self.manually_closed:
|
||||
return True
|
||||
# Utilize denormalized fields for faster check.
|
||||
return not (self.total_have_accepted < self.total_have_quantity and
|
||||
self.total_want_accepted < self.total_want_quantity)
|
||||
|
||||
from .models import TradeAcceptance # local import to avoid circular dependencies
|
||||
active_states = [
|
||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||
TradeAcceptance.AcceptanceState.SENT,
|
||||
TradeAcceptance.AcceptanceState.RECEIVED,
|
||||
TradeAcceptance.AcceptanceState.COMPLETED
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['manually_closed']),
|
||||
]
|
||||
|
||||
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):
|
||||
"""
|
||||
Through model for TradeOffer.have_cards.
|
||||
|
|
@ -83,13 +114,14 @@ class TradeOfferHaveCard(models.Model):
|
|||
trade_offer = models.ForeignKey(
|
||||
TradeOffer,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='trade_offer_have_cards'
|
||||
related_name='trade_offer_have_cards',
|
||||
db_index=True
|
||||
)
|
||||
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT)
|
||||
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True)
|
||||
quantity = models.PositiveIntegerField(default=1)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.card.name} x{self.quantity} (Have side for offer {self.trade_offer.hash})"
|
||||
return f"{self.card.name} x{self.quantity}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ("trade_offer", "card")
|
||||
|
|
@ -108,7 +140,7 @@ class TradeOfferWantCard(models.Model):
|
|||
quantity = models.PositiveIntegerField(default=1)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.card.name} x{self.quantity} (Want side for offer {self.trade_offer.hash})"
|
||||
return f"{self.card.name} x{self.quantity}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ("trade_offer", "card")
|
||||
|
|
@ -118,14 +150,17 @@ class TradeAcceptance(models.Model):
|
|||
ACCEPTED = 'ACCEPTED', 'Accepted'
|
||||
SENT = 'SENT', 'Sent'
|
||||
RECEIVED = 'RECEIVED', 'Received'
|
||||
COMPLETED = 'COMPLETED', 'Completed'
|
||||
THANKED_BY_INITIATOR = 'THANKED_BY_INITIATOR', 'Thanked by Initiator'
|
||||
THANKED_BY_ACCEPTOR = 'THANKED_BY_ACCEPTOR', 'Thanked by Acceptor'
|
||||
THANKED_BY_BOTH = 'THANKED_BY_BOTH', 'Thanked by Both'
|
||||
REJECTED_BY_INITIATOR = 'REJECTED_BY_INITIATOR', 'Rejected by Initiator'
|
||||
REJECTED_BY_ACCEPTOR = 'REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor'
|
||||
|
||||
trade_offer = models.ForeignKey(
|
||||
TradeOffer,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='acceptances'
|
||||
related_name='acceptances',
|
||||
db_index=True
|
||||
)
|
||||
accepted_by = models.ForeignKey(
|
||||
"accounts.FriendCode",
|
||||
|
|
@ -136,22 +171,115 @@ class TradeAcceptance(models.Model):
|
|||
requested_card = models.ForeignKey(
|
||||
"cards.Card",
|
||||
on_delete=models.PROTECT,
|
||||
related_name='accepted_requested'
|
||||
related_name='accepted_requested',
|
||||
db_index=True
|
||||
)
|
||||
# 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'
|
||||
related_name='accepted_offered',
|
||||
db_index=True
|
||||
)
|
||||
state = models.CharField(
|
||||
max_length=25,
|
||||
choices=AcceptanceState.choices,
|
||||
default=AcceptanceState.ACCEPTED
|
||||
default=AcceptanceState.ACCEPTED,
|
||||
db_index=True
|
||||
)
|
||||
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)
|
||||
|
||||
def mark_thanked(self, friend_code):
|
||||
"""
|
||||
Mark this acceptance as "thanked" by the given friend_code.
|
||||
Allowed transitions:
|
||||
- If the current state is RECEIVED:
|
||||
* If the initiator thanks, transition to THANKED_BY_INITIATOR.
|
||||
* If the acceptor thanks, transition to THANKED_BY_ACCEPTOR.
|
||||
- If already partially thanked:
|
||||
* If state is THANKED_BY_INITIATOR and the acceptor thanks, transition to THANKED_BY_BOTH.
|
||||
* If state is THANKED_BY_ACCEPTOR and the initiator thanks, transition to THANKED_BY_BOTH.
|
||||
Only parties involved in the trade (either the initiator or the acceptor) can mark it as thanked.
|
||||
"""
|
||||
if self.state not in [self.AcceptanceState.RECEIVED,
|
||||
self.AcceptanceState.THANKED_BY_INITIATOR,
|
||||
self.AcceptanceState.THANKED_BY_ACCEPTOR]:
|
||||
raise ValidationError("Cannot mark thanked in the current state.")
|
||||
|
||||
if friend_code == self.trade_offer.initiated_by:
|
||||
# Initiator is marking thanks.
|
||||
if self.state == self.AcceptanceState.RECEIVED:
|
||||
self.state = self.AcceptanceState.THANKED_BY_INITIATOR
|
||||
elif self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR:
|
||||
self.state = self.AcceptanceState.THANKED_BY_BOTH
|
||||
elif self.state == self.AcceptanceState.THANKED_BY_INITIATOR:
|
||||
# Already thanked by the initiator.
|
||||
return
|
||||
elif friend_code == self.accepted_by:
|
||||
# Acceptor is marking thanks.
|
||||
if self.state == self.AcceptanceState.RECEIVED:
|
||||
self.state = self.AcceptanceState.THANKED_BY_ACCEPTOR
|
||||
elif self.state == self.AcceptanceState.THANKED_BY_INITIATOR:
|
||||
self.state = self.AcceptanceState.THANKED_BY_BOTH
|
||||
elif self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR:
|
||||
# Already thanked by the acceptor.
|
||||
return
|
||||
else:
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied("You are not a party to this trade acceptance.")
|
||||
|
||||
self.save(update_fields=["state"])
|
||||
|
||||
@property
|
||||
def is_completed(self):
|
||||
"""
|
||||
Computed boolean property indicating whether the trade acceptance has been
|
||||
marked as thanked by one or both parties.
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Computed boolean property indicating whether the trade acceptance has been
|
||||
marked as thanked by one or both parties.
|
||||
"""
|
||||
return self.state == self.AcceptanceState.THANKED_BY_BOTH
|
||||
|
||||
@property
|
||||
def is_rejected(self):
|
||||
"""
|
||||
Computed boolean property that is True if the trade acceptance has been rejected
|
||||
by either the initiator or the acceptor.
|
||||
"""
|
||||
return self.state in {
|
||||
self.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_completed_or_rejected(self):
|
||||
"""
|
||||
Computed boolean property that is True if the trade acceptance is either completed
|
||||
(i.e., thanked) or rejected.
|
||||
"""
|
||||
return self.is_completed or self.is_rejected
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
"""
|
||||
Computed boolean property that is True if the trade acceptance is still active,
|
||||
meaning it is neither completed (thanked) nor rejected.
|
||||
"""
|
||||
return not self.is_completed_or_rejected
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validate that:
|
||||
|
|
@ -180,7 +308,9 @@ class TradeAcceptance(models.Model):
|
|||
self.AcceptanceState.ACCEPTED,
|
||||
self.AcceptanceState.SENT,
|
||||
self.AcceptanceState.RECEIVED,
|
||||
self.AcceptanceState.COMPLETED,
|
||||
self.AcceptanceState.THANKED_BY_INITIATOR,
|
||||
self.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||
self.AcceptanceState.THANKED_BY_BOTH,
|
||||
]
|
||||
|
||||
active_acceptances = self.trade_offer.acceptances.filter(state__in=active_states)
|
||||
|
|
@ -197,59 +327,119 @@ class TradeAcceptance(models.Model):
|
|||
if offered_count >= want_through_obj.quantity:
|
||||
raise ValidationError("This offered card has already been fully used.")
|
||||
|
||||
def update_state(self, new_state):
|
||||
def get_step_number(self):
|
||||
"""
|
||||
Return the step number for the current state.
|
||||
"""
|
||||
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:
|
||||
# .choices is a list of tuples, so we need to find the index of the tuple that contains the state.
|
||||
return (next(index for index, choice in enumerate(self.AcceptanceState.choices) if choice[0] == self.state) + 1)
|
||||
|
||||
def update_state(self, new_state, user):
|
||||
"""
|
||||
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.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_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,
|
||||
},
|
||||
}
|
||||
allowed = [x for x,y in self.get_allowed_state_transitions(user)]
|
||||
print(allowed)
|
||||
print(new_state)
|
||||
|
||||
if new_state not in allowed_transitions.get(self.state, {}):
|
||||
if new_state not in allowed:
|
||||
raise ValueError(f"Transition from {self.state} to {new_state} is not allowed.")
|
||||
|
||||
self.state = new_state
|
||||
self.save(update_fields=["state"])
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self.pk is None
|
||||
super().save(*args, **kwargs)
|
||||
if is_new and not self.hash:
|
||||
# Append "y" so all trade acceptance hashes differ from trade offers.
|
||||
self.hash = hashlib.md5((str(self.id) + "y").encode("utf-8")).hexdigest()[:8] + "y"
|
||||
super().save(update_fields=["hash"])
|
||||
|
||||
def __str__(self):
|
||||
return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, "
|
||||
f"accepted_by={self.accepted_by}, "
|
||||
f"requested_card={self.requested_card}, "
|
||||
f"offered_card={self.offered_card}, state={self.state})")
|
||||
|
||||
class Meta:
|
||||
# Unique constraints have been removed because validations now allow
|
||||
# multiple active acceptances per card based on the available quantity.
|
||||
pass
|
||||
def get_allowed_state_transitions(self, user):
|
||||
"""
|
||||
Returns a list of allowed state transitions as tuples (value, display_label)
|
||||
based on the current state of this trade acceptance.
|
||||
"""
|
||||
|
||||
if self.trade_offer.initiated_by in user.friend_codes.all():
|
||||
allowed_transitions = {
|
||||
self.AcceptanceState.ACCEPTED: {
|
||||
self.AcceptanceState.SENT,
|
||||
self.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
},
|
||||
self.AcceptanceState.SENT: {
|
||||
self.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
},
|
||||
self.AcceptanceState.RECEIVED: {
|
||||
self.AcceptanceState.THANKED_BY_INITIATOR,
|
||||
self.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
},
|
||||
self.AcceptanceState.THANKED_BY_INITIATOR: {
|
||||
self.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
},
|
||||
self.AcceptanceState.THANKED_BY_ACCEPTOR: {
|
||||
self.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
self.AcceptanceState.THANKED_BY_BOTH,
|
||||
},
|
||||
}
|
||||
elif self.accepted_by in user.friend_codes.all():
|
||||
allowed_transitions = {
|
||||
self.AcceptanceState.ACCEPTED: {
|
||||
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
},
|
||||
self.AcceptanceState.SENT: {
|
||||
self.AcceptanceState.RECEIVED,
|
||||
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
},
|
||||
self.AcceptanceState.RECEIVED: {
|
||||
self.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
},
|
||||
self.AcceptanceState.THANKED_BY_ACCEPTOR: {
|
||||
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
},
|
||||
self.AcceptanceState.THANKED_BY_INITIATOR: {
|
||||
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
self.AcceptanceState.THANKED_BY_BOTH,
|
||||
},
|
||||
}
|
||||
else:
|
||||
allowed_transitions = {}
|
||||
|
||||
allowed = allowed_transitions.get(self.state, {})
|
||||
|
||||
# Return as a list of tuples (state_value, human-readable label)
|
||||
return [(state, self.AcceptanceState(state).label) for state in allowed]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.db.models.signals import m2m_changed, post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from .models import TradeOffer
|
||||
from cards.models import Card
|
||||
from trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance
|
||||
|
||||
def check_trade_offer_rarity(instance):
|
||||
combined_cards = list(instance.have_cards.all()) + list(instance.want_cards.all())
|
||||
|
|
@ -19,4 +20,25 @@ def validate_have_cards_rarity(sender, instance, action, **kwargs):
|
|||
@receiver(m2m_changed, sender=TradeOffer.want_cards.through)
|
||||
def validate_want_cards_rarity(sender, instance, action, **kwargs):
|
||||
if action == "post_add":
|
||||
check_trade_offer_rarity(instance)
|
||||
check_trade_offer_rarity(instance)
|
||||
|
||||
@receiver(post_save, sender=TradeOfferHaveCard)
|
||||
@receiver(post_delete, sender=TradeOfferHaveCard)
|
||||
def update_aggregates_from_have_card(sender, instance, **kwargs):
|
||||
trade_offer = instance.trade_offer
|
||||
if trade_offer and hasattr(trade_offer, 'update_aggregates'):
|
||||
trade_offer.update_aggregates()
|
||||
|
||||
@receiver(post_save, sender=TradeOfferWantCard)
|
||||
@receiver(post_delete, sender=TradeOfferWantCard)
|
||||
def update_aggregates_from_want_card(sender, instance, **kwargs):
|
||||
trade_offer = instance.trade_offer
|
||||
if trade_offer and hasattr(trade_offer, 'update_aggregates'):
|
||||
trade_offer.update_aggregates()
|
||||
|
||||
@receiver(post_save, sender=TradeAcceptance)
|
||||
@receiver(post_delete, sender=TradeAcceptance)
|
||||
def update_aggregates_from_acceptance(sender, instance, **kwargs):
|
||||
trade_offer = instance.trade_offer
|
||||
if trade_offer and hasattr(trade_offer, 'update_aggregates'):
|
||||
trade_offer.update_aggregates()
|
||||
|
|
@ -8,47 +8,32 @@ def render_trade_offer(context, offer):
|
|||
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'
|
||||
)
|
||||
# Use the already prefetched acceptances.
|
||||
acceptances = offer.acceptances.all()
|
||||
have_cards_available = []
|
||||
want_cards_available = []
|
||||
|
||||
# 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)
|
||||
for card in offer.trade_offer_have_cards.all():
|
||||
if all(acc.requested_card_id != card.card_id for acc in acceptances):
|
||||
have_cards_available.append(card)
|
||||
|
||||
# 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)
|
||||
for card in offer.trade_offer_want_cards.all():
|
||||
if all(acc.offered_card_id != card.card_id for acc in acceptances):
|
||||
want_cards_available.append(card)
|
||||
|
||||
return {
|
||||
'offer': offer,
|
||||
'have_acceptances_data': have_acceptances_data,
|
||||
'want_acceptances_data': want_acceptances_data,
|
||||
'current_friend_code': current_friend_code,
|
||||
'have_cards_available': have_cards_available,
|
||||
'want_cards_available': want_cards_available,
|
||||
}
|
||||
|
||||
@register.inclusion_tag('templatetags/trade_acceptance.html', takes_context=True)
|
||||
def render_trade_acceptance(context, acceptance):
|
||||
"""
|
||||
Renders a simple trade acceptance view with a single row and simplified header/footer.
|
||||
"""
|
||||
return {
|
||||
"acceptance": acceptance,
|
||||
"request": context.get("request"),
|
||||
}
|
||||
345
trades/views.py
345
trades/views.py
|
|
@ -6,7 +6,7 @@ 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.db.models import Q, Case, When, Value, BooleanField, Prefetch, F
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.core.paginator import Paginator
|
||||
|
|
@ -14,7 +14,7 @@ from django.contrib import messages
|
|||
|
||||
from .models import TradeOffer, TradeAcceptance
|
||||
from .forms import (TradeOfferAcceptForm,
|
||||
TradeAcceptanceCreateForm, TradeAcceptanceUpdateForm, TradeOfferCreateForm)
|
||||
TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm)
|
||||
from cards.models import Card
|
||||
|
||||
class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
||||
|
|
@ -23,6 +23,11 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
|||
template_name = "trades/trade_offer_create.html"
|
||||
success_url = reverse_lazy("trade_offer_list")
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.friend_codes.exists():
|
||||
raise PermissionDenied("No friend codes available for your account.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
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.
|
||||
|
|
@ -31,16 +36,19 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
|||
|
||||
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)
|
||||
from cards.models import Card
|
||||
# Ensure available_cards is a proper QuerySet
|
||||
context["available_cards"] = Card.objects.all().order_by("name", "rarity__pk") \
|
||||
.select_related("rarity", "cardset") \
|
||||
.prefetch_related("decks")
|
||||
friend_codes = self.request.user.friend_codes.all()
|
||||
if "initiated_by" in self.request.GET:
|
||||
try:
|
||||
|
|
@ -54,7 +62,6 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
|||
return context
|
||||
|
||||
def form_valid(self, 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.")
|
||||
|
|
@ -62,18 +69,46 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
|||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
class TradeOfferListView(LoginRequiredMixin, ListView):
|
||||
model = TradeOffer # Fallback model; our context data will hold separate querysets.
|
||||
model = TradeOffer # Fallback model; our context data holds separate filtered querysets.
|
||||
template_name = "trades/trade_offer_list.html"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if request.user.is_authenticated and not request.user.friend_codes.exists():
|
||||
raise PermissionDenied("No friend codes available for your account.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
TradeOffer.objects.select_related('initiated_by')
|
||||
.prefetch_related(
|
||||
'trade_offer_have_cards__card',
|
||||
'trade_offer_want_cards__card',
|
||||
Prefetch(
|
||||
'acceptances',
|
||||
queryset=TradeAcceptance.objects.select_related('accepted_by', 'requested_card', 'offered_card')
|
||||
)
|
||||
)
|
||||
.order_by("-updated_at")
|
||||
.annotate(
|
||||
is_active=Case(
|
||||
When(
|
||||
manually_closed=False,
|
||||
total_have_quantity__gt=F('total_have_accepted'),
|
||||
total_want_quantity__gt=F('total_want_accepted'),
|
||||
then=Value(True)
|
||||
),
|
||||
default=Value(False),
|
||||
output_field=BooleanField()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
request = self.request
|
||||
show_closed = request.GET.get("show_closed", "false").lower() == "true"
|
||||
context["show_closed"] = show_closed
|
||||
|
||||
# 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
|
||||
|
||||
# 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:
|
||||
|
|
@ -90,28 +125,33 @@ class TradeOfferListView(LoginRequiredMixin, ListView):
|
|||
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]
|
||||
queryset = self.get_queryset().filter(initiated_by=selected_friend_code)
|
||||
if show_closed:
|
||||
queryset = queryset.filter(is_active=False)
|
||||
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]
|
||||
queryset = queryset.filter(is_active=True)
|
||||
|
||||
offers_page = request.GET.get("offers_page")
|
||||
offers_paginator = Paginator(queryset, 10)
|
||||
context["my_trade_offers_paginated"] = offers_paginator.get_page(offers_page)
|
||||
|
||||
# ----- Trade Acceptances involving the user -----
|
||||
# Update terminal states to include the thanked and rejected states.
|
||||
terminal_states = [
|
||||
TradeAcceptance.AcceptanceState.COMPLETED,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
]
|
||||
involved_acceptances = TradeAcceptance.objects.filter(
|
||||
involved_acceptances_qs = TradeAcceptance.objects.filter(
|
||||
Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code)
|
||||
).order_by("-updated_at")
|
||||
|
||||
if show_completed:
|
||||
involved_acceptances = involved_acceptances.filter(state__in=terminal_states)
|
||||
if show_closed:
|
||||
involved_acceptances = involved_acceptances_qs.filter(state__in=terminal_states)
|
||||
else:
|
||||
involved_acceptances = involved_acceptances.exclude(state__in=terminal_states)
|
||||
involved_acceptances = involved_acceptances_qs.exclude(state__in=terminal_states)
|
||||
|
||||
# ----- Split Acceptances into "Waiting for Your Response" and "Other" -----
|
||||
waiting_acceptances = involved_acceptances.filter(
|
||||
|
|
@ -119,22 +159,20 @@ class TradeOfferListView(LoginRequiredMixin, ListView):
|
|||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||
TradeAcceptance.AcceptanceState.RECEIVED,
|
||||
]) |
|
||||
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.SENT)
|
||||
Q(accepted_by=selected_friend_code, state__in=[
|
||||
TradeAcceptance.AcceptanceState.SENT
|
||||
])
|
||||
)
|
||||
other_trade_acceptances = involved_acceptances.exclude(pk__in=waiting_acceptances.values("pk"))
|
||||
other_party_trade_acceptances = involved_acceptances.exclude(pk__in=waiting_acceptances.values("pk"))
|
||||
|
||||
# ----- 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)
|
||||
other_party_paginator = Paginator(other_party_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)
|
||||
context["other_party_trade_acceptances_paginated"] = other_party_paginator.get_page(other_page)
|
||||
|
||||
return context
|
||||
|
||||
class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
|
||||
|
|
@ -144,7 +182,7 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
|
|||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
trade_offer = self.get_object()
|
||||
if trade_offer.initiated_by not in request.user.friend_codes.all():
|
||||
if trade_offer.initiated_by_id not in request.user.friend_codes.values_list("id", flat=True):
|
||||
raise PermissionDenied("You are not authorized to delete or close this trade offer.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
|
@ -152,7 +190,9 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
|
|||
context = super().get_context_data(**kwargs)
|
||||
trade_offer = self.get_object()
|
||||
terminal_states = [
|
||||
TradeAcceptance.AcceptanceState.COMPLETED,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
]
|
||||
|
|
@ -168,68 +208,133 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
|
|||
def post(self, request, *args, **kwargs):
|
||||
trade_offer = self.get_object()
|
||||
terminal_states = [
|
||||
TradeAcceptance.AcceptanceState.COMPLETED,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
||||
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):
|
||||
"""
|
||||
Reworked trade offer search view using POST.
|
||||
|
||||
This view allows users to search active trade offers based on the cards they have and/or want.
|
||||
The POST parameters (offered_cards and wanted_cards) are expected to be in the format 'card_id:quantity'.
|
||||
If both types of selections are provided, the resultant queryset must satisfy both conditions.
|
||||
Offers initiated by any of the user's friend codes are excluded.
|
||||
|
||||
When the request is AJAX (via X-Requested-With header), only the search results fragment
|
||||
(_search_results.html) is rendered. On GET (initial page load), the search results queryset
|
||||
is empty.
|
||||
"""
|
||||
model = TradeOffer
|
||||
context_object_name = "search_results"
|
||||
template_name = "trades/trade_offer_search.html"
|
||||
context_object_name = "trade_offers"
|
||||
paginate_by = 10
|
||||
http_method_names = ["get", "post"]
|
||||
|
||||
def parse_selections(self, selection_list):
|
||||
"""
|
||||
Parse a list of selections (each formatted as 'card_id:quantity') into a list of tuples.
|
||||
Defaults the quantity to 1 if missing.
|
||||
"""
|
||||
results = []
|
||||
for item in selection_list:
|
||||
parts = item.split(":")
|
||||
try:
|
||||
card_id = int(parts[0])
|
||||
except ValueError:
|
||||
continue # Skip invalid values.
|
||||
qty = 1
|
||||
if len(parts) > 1:
|
||||
try:
|
||||
qty = int(parts[1])
|
||||
except ValueError:
|
||||
qty = 1
|
||||
results.append((card_id, qty))
|
||||
return results
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset().filter(state=TradeOffer.State.INITIATED).prefetch_related("have_cards", "want_cards").select_related("initiated_by", "accepted_by")
|
||||
offered_card = self.request.GET.get("offered_card", "").strip()
|
||||
wanted_cards = self.request.GET.getlist("wanted_cards")
|
||||
|
||||
if not offered_card and not wanted_cards:
|
||||
return qs.none()
|
||||
from django.db.models import F
|
||||
# For a GET request (initial load), return an empty queryset.
|
||||
if self.request.method == "GET":
|
||||
return TradeOffer.objects.none()
|
||||
|
||||
if offered_card:
|
||||
try:
|
||||
offered_card_id = int(offered_card)
|
||||
except ValueError:
|
||||
qs = qs.none()
|
||||
else:
|
||||
qs = qs.filter(have_cards__id=offered_card_id)
|
||||
|
||||
if wanted_cards:
|
||||
valid_wanted_cards = []
|
||||
for card_str in wanted_cards:
|
||||
try:
|
||||
valid_wanted_cards.append(int(card_str))
|
||||
except ValueError:
|
||||
qs = qs.none()
|
||||
break
|
||||
if valid_wanted_cards:
|
||||
qs = qs.filter(want_cards__id__in=valid_wanted_cards)
|
||||
return qs
|
||||
# Parse the POST data for offered and wanted selections.
|
||||
offered_selections = self.parse_selections(self.request.POST.getlist("offered_cards"))
|
||||
wanted_selections = self.parse_selections(self.request.POST.getlist("wanted_cards"))
|
||||
|
||||
# If no selections are provided, return an empty queryset.
|
||||
if not offered_selections and not wanted_selections:
|
||||
return TradeOffer.objects.none()
|
||||
|
||||
qs = TradeOffer.objects.filter(
|
||||
manually_closed=False,
|
||||
total_have_accepted__lt=F("total_have_quantity"),
|
||||
total_want_accepted__lt=F("total_want_quantity")
|
||||
).exclude(initiated_by__in=self.request.user.friend_codes.all())
|
||||
|
||||
# Chain filters for offered selections (i.e. the user "has" cards).
|
||||
if offered_selections:
|
||||
for card_id, qty in offered_selections:
|
||||
qs = qs.filter(
|
||||
trade_offer_want_cards__card_id=card_id,
|
||||
trade_offer_want_cards__quantity__gte=qty,
|
||||
)
|
||||
|
||||
# Chain filters for wanted selections (i.e. the user "wants" cards).
|
||||
if wanted_selections:
|
||||
for card_id, qty in wanted_selections:
|
||||
qs = qs.filter(
|
||||
trade_offer_have_cards__card_id=card_id,
|
||||
trade_offer_have_cards__quantity__gte=qty,
|
||||
)
|
||||
|
||||
return qs.distinct()
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
# For POST, simply process the search through get().
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["offered_card"] = self.request.GET.get("offered_card", "")
|
||||
context["wanted_cards"] = self.request.GET.getlist("wanted_cards")
|
||||
context["available_cards"] = Card.objects.order_by("name", "rarity__pk").select_related("rarity", "cardset")
|
||||
from cards.models import Card
|
||||
# Populate available_cards to re-populate the multiselects.
|
||||
context["available_cards"] = Card.objects.all().order_by("name", "rarity__pk") \
|
||||
.select_related("rarity", "cardset")
|
||||
if self.request.method == "POST":
|
||||
context["offered_cards"] = self.request.POST.getlist("offered_cards")
|
||||
context["wanted_cards"] = self.request.POST.getlist("wanted_cards")
|
||||
else:
|
||||
context["offered_cards"] = []
|
||||
context["wanted_cards"] = []
|
||||
return context
|
||||
|
||||
def render_to_response(self, context, **response_kwargs):
|
||||
"""
|
||||
Render the AJAX fragment if the request is AJAX; otherwise, render the complete page.
|
||||
"""
|
||||
if self.request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
from django.shortcuts import render
|
||||
return render(self.request, "trades/_search_results.html", context)
|
||||
else:
|
||||
return super().render_to_response(context, **response_kwargs)
|
||||
|
||||
class TradeOfferDetailView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
Displays the details of a TradeOffer along with its active acceptances.
|
||||
|
|
@ -239,16 +344,55 @@ class TradeOfferDetailView(LoginRequiredMixin, DetailView):
|
|||
model = TradeOffer
|
||||
template_name = "trades/trade_offer_detail.html"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.friend_codes.exists():
|
||||
raise PermissionDenied("No friend codes available for your account.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
TradeOffer.objects.select_related('initiated_by')
|
||||
.prefetch_related(
|
||||
'trade_offer_have_cards__card',
|
||||
'trade_offer_want_cards__card',
|
||||
Prefetch(
|
||||
'acceptances',
|
||||
queryset=TradeAcceptance.objects.select_related(
|
||||
'accepted_by', 'requested_card', 'offered_card'
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_active=Case(
|
||||
When(manually_closed=False, then=Value(True)),
|
||||
default=Value(False),
|
||||
output_field=BooleanField()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
|
||||
# Define terminal (closed) acceptance states based on our new system:
|
||||
terminal_states = [
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
]
|
||||
context["acceptances"] = trade_offer.acceptances.filter(state__in=active_states)
|
||||
|
||||
# For example, if you want to separate active from terminal acceptances:
|
||||
context["acceptances"] = trade_offer.acceptances.all()
|
||||
|
||||
# Option 1: Filter active acceptances using the queryset lookup.
|
||||
context["active_acceptances"] = trade_offer.acceptances.exclude(state__in=terminal_states)
|
||||
|
||||
# Option 2: Or filter using the computed property (if you prefer to work with Python iterables):
|
||||
# context["active_acceptances"] = [acc for acc in trade_offer.acceptances.all() if acc.is_active]
|
||||
|
||||
user_friend_codes = self.request.user.friend_codes.all()
|
||||
|
||||
# Add context flag and deletion URL if the current user is the initiator
|
||||
|
|
@ -275,16 +419,40 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView):
|
|||
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:
|
||||
self.trade_offer = self.get_trade_offer()
|
||||
if self.trade_offer.initiated_by_id in request.user.friend_codes.values_list("id", flat=True) or not self.trade_offer.is_active:
|
||||
raise PermissionDenied("You cannot accept this trade offer.")
|
||||
if not request.user.friend_codes.exists():
|
||||
raise PermissionDenied("No friend codes available for your account.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_trade_offer(self):
|
||||
return (
|
||||
TradeOffer.objects.select_related('initiated_by')
|
||||
.prefetch_related(
|
||||
'trade_offer_want_cards__card',
|
||||
'trade_offer_have_cards__card',
|
||||
Prefetch(
|
||||
'acceptances',
|
||||
queryset=TradeAcceptance.objects.select_related(
|
||||
'accepted_by', 'requested_card', 'offered_card'
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_active=Case(
|
||||
When(manually_closed=False, then=Value(True)),
|
||||
default=Value(False),
|
||||
output_field=BooleanField()
|
||||
)
|
||||
)
|
||||
.get(pk=self.kwargs['offer_pk'])
|
||||
)
|
||||
|
||||
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()
|
||||
kwargs['trade_offer'] = self.trade_offer
|
||||
kwargs['friend_codes'] = self.request.user.friend_codes.all()
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
|
|
@ -301,18 +469,33 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, UpdateView):
|
|||
The allowed state transitions are provided via the form.
|
||||
"""
|
||||
model = TradeAcceptance
|
||||
form_class = TradeAcceptanceUpdateForm
|
||||
form_class = TradeAcceptanceTransitionForm
|
||||
template_name = "trades/trade_acceptance_update.html"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if self.object.accepted_by_id not in request.user.friend_codes.values_list("id", flat=True):
|
||||
raise PermissionDenied("You are not authorized to update this acceptance.")
|
||||
if not request.user.friend_codes.exists():
|
||||
raise PermissionDenied("No friend codes available for your account.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["friend_codes"] = self.request.user.friend_codes.all()
|
||||
# Pass the current instance to the form so it can set proper allowed transitions.
|
||||
kwargs["instance"] = self.object
|
||||
kwargs["user"] = self.request.user
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
new_state = form.cleaned_data["state"]
|
||||
#match the new state to the TradeAcceptance.AcceptanceState enum
|
||||
if new_state not in TradeAcceptance.AcceptanceState:
|
||||
form.add_error("state", "Invalid state transition.")
|
||||
return self.form_invalid(form)
|
||||
try:
|
||||
# Use the model's update_state logic.
|
||||
form.instance.update_state(form.cleaned_data["state"])
|
||||
# pass the new state and the current user to the update_state method
|
||||
form.instance.update_state(new_state, self.request.user)
|
||||
except ValueError as e:
|
||||
form.add_error("state", str(e))
|
||||
return self.form_invalid(form)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue