Some optimizations to trade_offers to reduce loading times

This commit is contained in:
badblocks 2025-03-15 15:23:00 -07:00
parent 0ac8ac8d5c
commit 9ce5d525b3
13 changed files with 255 additions and 222 deletions

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-14 05:35 # Generated by Django 5.1.2 on 2025-03-15 22:05
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@ -35,6 +35,7 @@ class Migration(migrations.Migration):
name='Rarity', name='Rarity',
fields=[ fields=[
('id', models.AutoField(primary_key=True, serialize=False)), ('id', models.AutoField(primary_key=True, serialize=False)),
('normalized_id', models.IntegerField()),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('icons', models.CharField(max_length=64)), ('icons', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),

View file

@ -1,4 +1,6 @@
from django.db import models from django.db import models
from django.db.models import Prefetch
from django.apps import apps
class DeckNameTranslation(models.Model): class DeckNameTranslation(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
@ -55,6 +57,7 @@ class Deck(models.Model):
class Rarity(models.Model): class Rarity(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
normalized_id = models.IntegerField(null=False)
name = models.CharField(max_length=64) name = models.CharField(max_length=64)
icons = models.CharField(max_length=64) icons = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@ -63,14 +66,26 @@ class Rarity(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
@property # Custom Manager for Card model
def normalized_id(self): class CardPrefetchManager(models.Manager):
""" def get_queryset(self):
For trading equivalence: treat Special Art Rare (pk 7) and Super Rare (pk 6) as the same. return (
""" super()
if self.pk in (6, 7): .get_queryset()
return 6 .select_related("cardset", "rarity")
return self.pk .prefetch_related(
"decks",
"decks__cardset",
)
)
class CardManager(models.Manager):
def get_queryset(self):
return (
super()
.get_queryset()
.select_related("cardset", "rarity")
)
class Card(models.Model): class Card(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
@ -82,6 +97,10 @@ class Card(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
# Use the custom manager to ensure optimized querysets everywhere.
objects = CardPrefetchManager()
objects_no_prefetch = CardManager()
def __str__(self): def __str__(self):
# For display, we show the original rarity icons. # For display, we show the original rarity icons.
return f"{self.name} {self.rarity.icons} {self.cardset.name}" return f"{self.name} {self.rarity.icons} {self.cardset.name}"

View file

@ -14,106 +14,58 @@ from django.http import HttpResponseRedirect
class HomePageView(TemplateView): class HomePageView(TemplateView):
template_name = "home/home.html" template_name = "home/home.html"
def get_base_trade_offer_queryset(self):
"""
Returns a queryset for TradeOffer that includes prefetches and denormalized aggregates.
"""
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,
]
have_cards_prefetch = Prefetch(
'have_cards',
queryset=Card.objects.annotate(
trade_offer_count=Count("trade_offers_have")
).order_by("trade_offer_count", "id")
)
want_cards_prefetch = Prefetch(
'want_cards',
queryset=Card.objects.annotate(
trade_offer_count=Count("trade_offers_want")
).order_by("trade_offer_count", "id")
)
qs = (
TradeOffer.objects.all()
.prefetch_related(
have_cards_prefetch,
"have_cards__decks",
"have_cards__rarity",
"have_cards__cardset",
want_cards_prefetch,
"want_cards__decks",
"want_cards__rarity",
"want_cards__cardset",
"acceptances",
)
.select_related("initiated_by__user")
)
return qs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["cards"] = Card.objects.all() \ context["cards"] = Card.objects.all().order_by("name", "rarity__pk")
.order_by("name", "rarity__pk") \
.select_related("rarity", "cardset") \
.prefetch_related("decks")
# Reuse base trade offer queryset for market stats # Reuse base trade offer queryset for market stats
base_offer_qs = self.get_base_trade_offer_queryset().filter(is_closed=False) base_offer_qs = TradeOffer.objects.filter(is_closed=False)
# Recent Offers # Recent Offers
recent_offers_qs = base_offer_qs.order_by("-created_at")[:10] recent_offers_qs = base_offer_qs.order_by("-created_at")
context["recent_offers"] = list(recent_offers_qs)[:5] context["recent_offers"] = recent_offers_qs[:6]
# Most Offered Cards # Most Offered Cards
context["most_offered_cards"] = ( context["most_offered_cards"] = (
Card.objects.filter(tradeofferhavecard__isnull=False) Card.objects_no_prefetch.filter(tradeofferhavecard__isnull=False)
.annotate(offer_count=Sum("tradeofferhavecard__quantity")) .annotate(offer_count=Sum("tradeofferhavecard__quantity"))
.order_by("-offer_count") .order_by("-offer_count", "?")[:6]
.select_related("rarity", "cardset")
.prefetch_related("decks")[:5]
) )
# Most Wanted Cards # Most Wanted Cards
context["most_wanted_cards"] = ( context["most_wanted_cards"] = (
Card.objects.filter(tradeofferwantcard__isnull=False) Card.objects_no_prefetch.filter(tradeofferwantcard__isnull=False)
.annotate(offer_count=Sum("tradeofferwantcard__quantity")) .annotate(offer_count=Sum("tradeofferwantcard__quantity"))
.order_by("-offer_count") .order_by("-offer_count", "?")[:6]
.select_related("rarity", "cardset")
.prefetch_related("decks")[:5]
) )
# Least Offered Cards # Least Offered Cards
context["least_offered_cards"] = ( context["least_offered_cards"] = (
Card.objects.annotate(offer_count=Sum("tradeofferhavecard__quantity")) Card.objects_no_prefetch.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
.order_by("offer_count", "?")[:5] .order_by("offer_count", "?")[:6]
) )
# Featured Offers grouped by rarity
all_offers = base_offer_qs.order_by("created_at")
featured = {} featured = {}
featured["All"] = all_offers[:5] featured["All"] = base_offer_qs.order_by("created_at")[:6]
grouped = defaultdict(list)
for offer in all_offers: normalized_ids = list(
normalized_ids = {card.rarity.normalized_id for card in offer.have_cards.all() if card.rarity} Rarity.objects.filter(pk__lte=5).values_list("normalized_id", flat=True).distinct()
for norm in normalized_ids: )
grouped[norm].append(offer)
norm_ids_available = list(grouped.keys()) rarity_map = {rarity.normalized_id: rarity.icons for rarity in Rarity.objects.filter(pk__lte=5)}
rareness_qs = Rarity.objects.filter(pk__in=[6] + [nid for nid in norm_ids_available if nid != 6])
rarity_map = {rarity.pk: rarity.icons for rarity in rareness_qs} # For each normalized id (sorted descending), filter base offers that have a related card with that rarity.
for norm in sorted(grouped.keys(), reverse=True): for norm in sorted(normalized_ids, reverse=True):
offers = grouped[norm] offers_qs = base_offer_qs.filter(
have_cards__rarity__normalized_id=norm
# or want cards, but all offers share the same rarity so checking have_cards is enough
# TODO: attach rarity to offer so we don't need to do this
).order_by("created_at").distinct()[:6]
icon_label = rarity_map.get(norm) icon_label = rarity_map.get(norm)
if icon_label: if icon_label:
featured[icon_label] = offers[:5] featured[icon_label] = offers_qs
context["featured_offers"] = featured context["featured_offers"] = featured
return context return context

View file

@ -4,6 +4,7 @@
"pk": 1, "pk": 1,
"fields": { "fields": {
"icons": "🔷", "icons": "🔷",
"normalized_id": 1,
"name": "Common", "name": "Common",
"created_at": "2025-02-16T06:54:40.993Z", "created_at": "2025-02-16T06:54:40.993Z",
"updated_at": "2025-02-16T06:54:40.993Z" "updated_at": "2025-02-16T06:54:40.993Z"
@ -14,6 +15,7 @@
"pk": 2, "pk": 2,
"fields": { "fields": {
"icons": "🔷🔷", "icons": "🔷🔷",
"normalized_id": 2,
"name": "Uncommon", "name": "Uncommon",
"created_at": "2025-02-16T06:54:44.213Z", "created_at": "2025-02-16T06:54:44.213Z",
"updated_at": "2025-02-16T06:54:44.213Z" "updated_at": "2025-02-16T06:54:44.213Z"
@ -24,6 +26,7 @@
"pk": 3, "pk": 3,
"fields": { "fields": {
"icons": "🔷🔷🔷", "icons": "🔷🔷🔷",
"normalized_id": 3,
"name": "Rare", "name": "Rare",
"created_at": "2025-02-16T06:54:47.297Z", "created_at": "2025-02-16T06:54:47.297Z",
"updated_at": "2025-02-16T06:54:47.297Z" "updated_at": "2025-02-16T06:54:47.297Z"
@ -34,6 +37,7 @@
"pk": 4, "pk": 4,
"fields": { "fields": {
"icons": "🔷🔷🔷🔷", "icons": "🔷🔷🔷🔷",
"normalized_id": 4,
"name": "Double Rare", "name": "Double Rare",
"created_at": "2025-02-16T06:54:50.363Z", "created_at": "2025-02-16T06:54:50.363Z",
"updated_at": "2025-02-16T06:54:50.363Z" "updated_at": "2025-02-16T06:54:50.363Z"
@ -44,6 +48,7 @@
"pk": 5, "pk": 5,
"fields": { "fields": {
"icons": "⭐️", "icons": "⭐️",
"normalized_id": 5,
"name": "Full Art Rare", "name": "Full Art Rare",
"created_at": "2025-02-16T06:54:59.888Z", "created_at": "2025-02-16T06:54:59.888Z",
"updated_at": "2025-02-16T06:54:59.888Z" "updated_at": "2025-02-16T06:54:59.888Z"
@ -54,6 +59,7 @@
"pk": 6, "pk": 6,
"fields": { "fields": {
"icons": "⭐️⭐️", "icons": "⭐️⭐️",
"normalized_id": 6,
"name": "Super Rare", "name": "Super Rare",
"created_at": "2025-02-16T06:55:02.853Z", "created_at": "2025-02-16T06:55:02.853Z",
"updated_at": "2025-02-16T06:55:02.853Z" "updated_at": "2025-02-16T06:55:02.853Z"
@ -64,6 +70,7 @@
"pk": 7, "pk": 7,
"fields": { "fields": {
"icons": "🌟🌟", "icons": "🌟🌟",
"normalized_id": 6,
"name": "Special Art Rare", "name": "Special Art Rare",
"created_at": "2025-02-16T06:55:02.853Z", "created_at": "2025-02-16T06:55:02.853Z",
"updated_at": "2025-02-16T06:55:02.853Z" "updated_at": "2025-02-16T06:55:02.853Z"
@ -74,6 +81,7 @@
"pk": 8, "pk": 8,
"fields": { "fields": {
"icons": "⭐️⭐️⭐️", "icons": "⭐️⭐️⭐️",
"normalized_id": 7,
"name": "Immersive Rare", "name": "Immersive Rare",
"created_at": "2025-02-16T06:55:05.728Z", "created_at": "2025-02-16T06:55:05.728Z",
"updated_at": "2025-02-16T06:55:05.728Z" "updated_at": "2025-02-16T06:55:05.728Z"
@ -84,6 +92,7 @@
"pk": 9, "pk": 9,
"fields": { "fields": {
"icons": "👑", "icons": "👑",
"normalized_id": 8,
"name": "Crown Rare", "name": "Crown Rare",
"created_at": "2025-02-16T06:55:13.907Z", "created_at": "2025-02-16T06:55:13.907Z",
"updated_at": "2025-02-16T06:55:13.907Z" "updated_at": "2025-02-16T06:55:13.907Z"
@ -94,6 +103,7 @@
"pk": 10, "pk": 10,
"fields": { "fields": {
"icons": "🅿️", "icons": "🅿️",
"normalized_id": 9,
"name": "Promo", "name": "Promo",
"created_at": "2025-02-16T06:55:13.907Z", "created_at": "2025-02-16T06:55:13.907Z",
"updated_at": "2025-02-16T06:55:13.907Z" "updated_at": "2025-02-16T06:55:13.907Z"

View file

@ -40,7 +40,7 @@
{% block javascript_head %}{% endblock %} {% block javascript_head %}{% endblock %}
</head> </head>
<body class="min-h-screen bg-base-200"> <div class="min-h-screen bg-base-200">
<!-- Header and Navigation --> <!-- Header and Navigation -->
<div class="navbar bg-base-100 shadow-sm"> <div class="navbar bg-base-100 shadow-sm">
<div class="navbar-start"> <div class="navbar-start">
@ -116,7 +116,6 @@
<li><a href="{% url 'account_logout' %}">Sign Out</a></li> <li><a href="{% url 'account_logout' %}">Sign Out</a></li>
</ul> </ul>
</div> </div>
</div>
{% else %} {% else %}
<div class="flex gap-2"> <div class="flex gap-2">
<a class="btn btn-primary" href="{% url 'account_login' %}">Login</a> <a class="btn btn-primary" href="{% url 'account_login' %}">Login</a>

View file

@ -25,7 +25,7 @@ Expected variables:
</select> </select>
</div> </div>
{% else %} {% else %}
<input type="hidden" name="{{ field_name }}" value="{{ friend_codes.0.pk }}"> <input type="hidden" id="{{ field_name }}" name="{{ field_name }}" value="{{ friend_codes.0.pk }}">
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}

View file

@ -6,25 +6,54 @@
{% block content %} {% block content %}
<div class="container mx-auto max-w-2xl mt-6"> <div class="container mx-auto max-w-2xl mt-6">
<h2 class="text-2xl font-bold">Trade Offer Details</h2> <h2 class="text-2xl font-bold">Trade Offer Details</h2>
<div> <div class="flex justify-center mt-10">
{% render_trade_offer object %} {% render_trade_offer object %}
</div> </div>
{% if acceptance_form %} {% if acceptance_form %}
<h3 class="text-xl font-semibold mt-6">Accept This Offer</h3> <div class="w-3/4 mx-auto mt-4">
<div class="card p-4"> <h3 class="text-xl font-semibold mt-6 mb-2">Accept This Offer</h3>
<form method="post" action="{% url 'trade_acceptance_create' offer_pk=object.pk %}"> <form method="post" action="{% url 'trade_acceptance_create' offer_pk=object.pk %}">
{% csrf_token %} {% csrf_token %}
{{ acceptance_form.as_p }} <input type="hidden" name="next" value="{% url 'trade_offer_detail' pk=object.pk %}">
<button type="submit" class="btn btn-primary">Submit Acceptance</button>
</form>
</div>
{% endif %}
<div class="mt-6"> <div class="mb-4">
{% if is_initiator %} <label for="{{ acceptance_form.offered_card.id_for_label }}" class="block text-sm font-medium text-gray-700">I have:</label>
<a href="{{ delete_close_url }}" class="btn btn-danger">Delete/Close Trade Offer</a> {{ acceptance_form.offered_card }}
{% for error in acceptance_form.offered_card.errors %}
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
{% endfor %}
</div>
<div class="mb-4">
<label for="{{ acceptance_form.requested_card.id_for_label }}" class="block text-sm font-medium text-gray-700">I want:</label>
{{ acceptance_form.requested_card }}
{% for error in acceptance_form.requested_card.errors %}
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
{% endfor %}
</div>
<div class="mb-4">
{% if not acceptance_form.accepted_by.field.widget.is_hidden %}
<label for="{{ acceptance_form.accepted_by.id_for_label }}" class="block text-sm font-medium text-gray-700">
Accepted By
</label>
{% endif %}
{{ acceptance_form.accepted_by }}
{% for error in acceptance_form.accepted_by.errors %}
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
{% endfor %}
</div>
</div>
{% endif %} {% endif %}
<a href="{% url 'trade_offer_list' %}" class="btn btn-secondary">Back to Trade Offers</a>
<div class="mt-6 flex justify-between">
<a href="{% url 'trade_offer_list' %}" class="btn btn-secondary">Back to Trade Offers</a>
{% if is_initiator %}
<a href="{{ delete_close_url }}" class="btn btn-danger">Delete/Close Trade Offer</a>
{% else %}
<button type="submit" class="btn btn-primary">Submit Acceptance</button>
{% endif %}
</form>
</div> </div>
</div> </div>
{% endblock content %} {% endblock content %}

View file

@ -57,8 +57,7 @@
<div <div
id="my-trade-offers" id="my-trade-offers"
x-data="tradeOffersPagination('{% url 'trade_offer_my_list' %}?ajax_section=my_trade_offers')" x-data="tradeOffersPagination('{% url 'trade_offer_my_list' %}?ajax_section=my_trade_offers')"
x-init="init()" x-init="init()">
>
{% include "trades/_trade_offer_list_paginated.html" with offers=my_trade_offers_paginated %} {% include "trades/_trade_offer_list_paginated.html" with offers=my_trade_offers_paginated %}
</div> </div>
</section> </section>

View file

@ -29,31 +29,38 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
Expects the caller to pass: Expects the caller to pass:
- trade_offer: the instance of TradeOffer this acceptance is for. - trade_offer: the instance of TradeOffer this acceptance is for.
- friend_codes: a queryset of FriendCode objects for the current user. - friend_codes: a queryset of FriendCode objects for the current user.
- default_friend_code (optional): the user's default FriendCode.
It filters available requested and offered cards based on what's still available. It filters available requested and offered cards based on what's still available.
""" """
class Meta: class Meta:
model = TradeAcceptance model = TradeAcceptance
fields = ["accepted_by", "requested_card", "offered_card"] fields = ["accepted_by", "requested_card", "offered_card"]
def __init__(self, *args, trade_offer=None, friend_codes=None, **kwargs): def __init__(self, *args, trade_offer=None, friend_codes=None, default_friend_code=None, **kwargs):
if trade_offer is None: if trade_offer is None:
raise ValueError("trade_offer must be provided to filter choices.") raise ValueError("trade_offer must be provided to filter choices.")
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.trade_offer = trade_offer self.trade_offer = trade_offer
# Filter accepted_by to those friend codes that belong to the current user.
if friend_codes is None: if friend_codes is None:
raise ValueError("friend_codes must be provided") raise ValueError("friend_codes must be provided")
# Set the accepted_by queryset to the user's friend codes.
self.fields["accepted_by"].queryset = friend_codes self.fields["accepted_by"].queryset = friend_codes
# Update active_states to include only states that mean the acceptance is still "open". # If the user only has one friend code, preset the field and use a HiddenInput.
if friend_codes.count() == 1:
self.initial["accepted_by"] = friend_codes.first().pk
self.fields["accepted_by"].widget = forms.HiddenInput()
# Otherwise, if a default friend code is provided and it is in the queryset, preselect it.
elif default_friend_code and friend_codes.filter(pk=default_friend_code.pk).exists():
self.initial["accepted_by"] = default_friend_code.pk
# Update available requested_card choices from the TradeOffer's "have" side.
active_states = [ active_states = [
TradeAcceptance.AcceptanceState.ACCEPTED, TradeAcceptance.AcceptanceState.ACCEPTED,
TradeAcceptance.AcceptanceState.SENT, TradeAcceptance.AcceptanceState.SENT,
TradeAcceptance.AcceptanceState.RECEIVED, TradeAcceptance.AcceptanceState.RECEIVED,
] ]
# Build available requested_card choices from the TradeOffer's "have" side.
available_requested_ids = [] available_requested_ids = []
for through_obj in trade_offer.trade_offer_have_cards.all(): for through_obj in trade_offer.trade_offer_have_cards.all():
active_count = trade_offer.acceptances.filter( active_count = trade_offer.acceptances.filter(
@ -64,7 +71,7 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
available_requested_ids.append(through_obj.card.id) available_requested_ids.append(through_obj.card.id)
self.fields["requested_card"].queryset = Card.objects.filter(id__in=available_requested_ids) 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. # Update available offered_card choices from the TradeOffer's "want" side.
available_offered_ids = [] available_offered_ids = []
for through_obj in trade_offer.trade_offer_want_cards.all(): for through_obj in trade_offer.trade_offer_want_cards.all():
active_count = trade_offer.acceptances.filter( active_count = trade_offer.acceptances.filter(

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-14 05:35 # Generated by Django 5.1.2 on 2025-03-15 22:05
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View file

@ -1,11 +1,39 @@
from django.db import models from django.db import models
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q, Count, Prefetch, F, Sum
import hashlib import hashlib
from cards.models import Card from cards.models import Card
from accounts.models import FriendCode from accounts.models import FriendCode
class TradeOfferManager(models.Manager):
def get_queryset(self):
queryset = super().get_queryset().select_related("initiated_by", "initiated_by__user")
queryset = queryset.prefetch_related(
Prefetch(
"trade_offer_want_cards",
queryset=TradeOfferWantCard.objects.select_related("card").annotate(
total_quantity=Sum("quantity"),
total_accepted=Sum("qty_accepted")
).order_by("total_quantity", "id")
),
Prefetch(
"trade_offer_have_cards",
queryset=TradeOfferHaveCard.objects.select_related("card").annotate(
total_quantity=Sum("quantity"),
total_accepted=Sum("qty_accepted")
).order_by("total_quantity", "id")
),
Prefetch(
"acceptances",
queryset=TradeAcceptance.objects.select_related("accepted_by", "accepted_by__user", "requested_card", "offered_card")
),
).order_by("-updated_at")
return queryset
class TradeOffer(models.Model): class TradeOffer(models.Model):
objects = TradeOfferManager()
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
is_closed = models.BooleanField(default=False, db_index=True) is_closed = models.BooleanField(default=False, db_index=True)
hash = models.CharField(max_length=9, editable=False) hash = models.CharField(max_length=9, editable=False)

View file

@ -1,8 +1,9 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models.signals import m2m_changed, post_save, post_delete from django.db.models.signals import m2m_changed, post_save, post_delete, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from .models import TradeOffer from .models import TradeOffer
from cards.models import Card from cards.models import Card
from django.db.models import F
from trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance from trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance
def check_trade_offer_rarity(instance): def check_trade_offer_rarity(instance):
@ -31,46 +32,79 @@ ACTIVE_STATES = [
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
] ]
def update_qty_for_trade_offer(trade_offer, card, side): def adjust_qty_for_trade_offer(trade_offer, card, side, delta):
"""
Increment (or decrement) qty_accepted by delta for the given card on the specified side.
"""
if side == 'have': if side == 'have':
count = TradeAcceptance.objects.filter(
trade_offer=trade_offer,
requested_card=card,
state__in=ACTIVE_STATES
).count()
TradeOfferHaveCard.objects.filter( TradeOfferHaveCard.objects.filter(
trade_offer=trade_offer, trade_offer=trade_offer,
card=card card=card
).update(qty_accepted=count) ).update(qty_accepted=F('qty_accepted') + delta)
if count >= TradeOfferHaveCard.objects.filter(trade_offer=trade_offer, card=card).first().quantity:
trade_offer.is_closed = True
trade_offer.save(update_fields=["is_closed"])
elif side == 'want': elif side == 'want':
count = TradeAcceptance.objects.filter(
trade_offer=trade_offer,
offered_card=card,
state__in=ACTIVE_STATES
).count()
TradeOfferWantCard.objects.filter( TradeOfferWantCard.objects.filter(
trade_offer=trade_offer, trade_offer=trade_offer,
card=card card=card
).update(qty_accepted=count) ).update(qty_accepted=F('qty_accepted') + delta)
if count >= TradeOfferWantCard.objects.filter(trade_offer=trade_offer, card=card).first().quantity: def update_trade_offer_closed_status(trade_offer):
trade_offer.is_closed = True """
trade_offer.save(update_fields=["is_closed"]) Check if both sides of the trade offer meet the quantity requirement.
Mark the trade_offer as closed if all cards have qty_accepted
greater than or equal to quantity; otherwise, mark it as open.
"""
have_complete = not TradeOfferHaveCard.objects.filter(
trade_offer=trade_offer,
qty_accepted__lt=F('quantity')
).exists()
want_complete = not TradeOfferWantCard.objects.filter(
trade_offer=trade_offer,
qty_accepted__lt=F('quantity')
).exists()
closed = have_complete or want_complete
if trade_offer.is_closed != closed:
trade_offer.is_closed = closed
trade_offer.save(update_fields=["is_closed"])
# Pre-save signal to capture the original state before any changes.
@receiver(pre_save, sender=TradeAcceptance)
def trade_acceptance_pre_save(sender, instance, **kwargs):
if instance.pk:
old_instance = TradeAcceptance.objects.get(pk=instance.pk)
instance._old_state = old_instance.state
def update_all_qty(instance): # Post-save signal to adjust qty_accepted incrementally.
trade_offer = instance.trade_offer
update_qty_for_trade_offer(trade_offer, instance.requested_card, 'have')
update_qty_for_trade_offer(trade_offer, instance.offered_card, 'want')
@receiver(post_save, sender=TradeAcceptance) @receiver(post_save, sender=TradeAcceptance)
def trade_acceptance_post_save(sender, instance, **kwargs): def trade_acceptance_post_save(sender, instance, created, **kwargs):
update_all_qty(instance) delta = 0
if created:
# For a new acceptance, increment only if the state is active.
if instance.state in ACTIVE_STATES:
delta = 1
else:
old_state = getattr(instance, '_old_state', None)
if old_state is not None:
# Transition from active to non-active (e.g. a rejection)
if old_state in ACTIVE_STATES and instance.state not in ACTIVE_STATES:
delta = -1
# Transition from non-active to active
elif old_state not in ACTIVE_STATES and instance.state in ACTIVE_STATES:
delta = 1
if delta != 0:
trade_offer = instance.trade_offer
# Update the "have" side using the requested_card.
adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta)
# Update the "want" side using the offered_card.
adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta)
update_trade_offer_closed_status(trade_offer)
# Post-delete signal to decrement qty_accepted if the deleted acceptance was active.
@receiver(post_delete, sender=TradeAcceptance) @receiver(post_delete, sender=TradeAcceptance)
def trade_acceptance_post_delete(sender, instance, **kwargs): def trade_acceptance_post_delete(sender, instance, **kwargs):
update_all_qty(instance) if instance.state in ACTIVE_STATES:
delta = -1
trade_offer = instance.trade_offer
adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta)
adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta)
update_trade_offer_closed_status(trade_offer)

View file

@ -74,31 +74,7 @@ class TradeOfferAllListView(ListView):
context["show_closed"] = show_closed context["show_closed"] = show_closed
# Build the queryset with our related objects. # Build the queryset with our related objects.
queryset = ( queryset = TradeOffer.objects
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').prefetch_related(
'requested_card__decks',
'offered_card__decks',
'requested_card__rarity',
'offered_card__rarity',
'requested_card__cardset',
'offered_card__cardset',
)
),
'trade_offer_have_cards__card__decks',
'trade_offer_want_cards__card__decks',
'trade_offer_have_cards__card__rarity',
'trade_offer_want_cards__card__rarity',
'trade_offer_have_cards__card__cardset',
'trade_offer_want_cards__card__cardset'
)
.order_by("-updated_at")
)
if show_closed: if show_closed:
queryset = queryset.filter(is_closed=True) queryset = queryset.filter(is_closed=True)
else: else:
@ -116,31 +92,7 @@ class TradeOfferAllListView(ListView):
page = self.request.GET.get("page") page = self.request.GET.get("page")
show_closed = self.request.GET.get("show_closed", "false").lower() == "true" show_closed = self.request.GET.get("show_closed", "false").lower() == "true"
queryset = ( queryset = TradeOffer.objects.all()
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').prefetch_related(
'requested_card__decks',
'offered_card__decks',
'requested_card__rarity',
'offered_card__rarity',
'requested_card__cardset',
'offered_card__cardset',
)
),
'trade_offer_have_cards__card__decks',
'trade_offer_want_cards__card__decks',
'trade_offer_have_cards__card__rarity',
'trade_offer_want_cards__card__rarity',
'trade_offer_have_cards__card__cardset',
'trade_offer_want_cards__card__cardset'
)
.order_by("-updated_at")
)
if show_closed: if show_closed:
queryset = queryset.filter(is_closed=True) queryset = queryset.filter(is_closed=True)
else: else:
@ -162,20 +114,6 @@ class TradeOfferMyListView(LoginRequiredMixin, ListView):
raise PermissionDenied("No friend codes available for your account.") raise PermissionDenied("No friend codes available for your account.")
return super().dispatch(request, *args, **kwargs) 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")
)
def get_selected_friend_code(self): def get_selected_friend_code(self):
friend_codes = self.request.user.friend_codes.all() friend_codes = self.request.user.friend_codes.all()
friend_code_param = self.request.GET.get("friend_code") friend_code_param = self.request.GET.get("friend_code")
@ -448,21 +386,6 @@ class TradeOfferDetailView(LoginRequiredMixin, DetailView):
model = TradeOffer model = TradeOffer
template_name = "trades/trade_offer_detail.html" template_name = "trades/trade_offer_detail.html"
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'
)
)
)
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
trade_offer = self.get_object() trade_offer = self.get_object()
@ -492,10 +415,15 @@ class TradeOfferDetailView(LoginRequiredMixin, DetailView):
else: else:
context["is_initiator"] = False context["is_initiator"] = False
# Determine the user's default friend code (or fallback as needed).
default_friend_code = self.request.user.default_friend_code or user_friend_codes.first()
# If the current user is not the initiator and the offer is open, allow a new acceptance. # 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: if trade_offer.initiated_by not in user_friend_codes and not trade_offer.is_closed:
context["acceptance_form"] = TradeAcceptanceCreateForm( context["acceptance_form"] = TradeAcceptanceCreateForm(
trade_offer=trade_offer, friend_codes=user_friend_codes trade_offer=trade_offer,
friend_codes=user_friend_codes,
default_friend_code=default_friend_code
) )
return context return context
@ -538,11 +466,38 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView):
kwargs['friend_codes'] = self.request.user.friend_codes.all() kwargs['friend_codes'] = self.request.user.friend_codes.all()
return kwargs return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["trade_offer"] = self.trade_offer
return context
def form_valid(self, form): def form_valid(self, form):
form.instance.trade_offer = self.trade_offer form.instance.trade_offer = self.trade_offer
self.object = form.save() self.object = form.save()
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
def form_invalid(self, form):
"""
If the form submission includes a 'next' URL (sent as a hidden field from the detail page),
render the trade offer detail template for a better UX. Otherwise, fall back to the default
CreateView behavior.
"""
next_url = self.request.POST.get("next")
if next_url:
friend_codes = self.request.user.friend_codes.all()
is_initiator = self.trade_offer.initiated_by in friend_codes
context = {
"object": self.trade_offer,
"trade_offer": self.trade_offer,
"acceptance_form": form,
"friend_codes": friend_codes,
"is_initiator": is_initiator,
"delete_close_url": reverse_lazy("trade_offer_delete", kwargs={"pk": self.trade_offer.pk}) if is_initiator else None,
}
# Render the detail page with the form errors
return render(self.request, "trades/trade_offer_detail.html", context)
return super().form_invalid(form)
def get_success_url(self): def get_success_url(self):
return reverse_lazy("trade_offer_detail", kwargs={"pk": self.trade_offer.pk}) return reverse_lazy("trade_offer_detail", kwargs={"pk": self.trade_offer.pk})