progress on conversion to tailwind

This commit is contained in:
badblocks 2025-03-06 21:28:36 -08:00
parent 6a872124c6
commit 6e2843c60e
110 changed files with 4997 additions and 1691 deletions

View file

@ -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

View 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'),
),
]

View file

@ -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

View file

@ -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.")

View file

@ -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,
}

View file

@ -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"),
]

View file

@ -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})