Fix create trade offer flow and other related bugs

This commit is contained in:
badblocks 2025-03-26 11:38:02 -07:00
parent f3a1366269
commit 65ca344582
40 changed files with 867 additions and 278 deletions

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-20 00:08
# Generated by Django 5.1.2 on 2025-03-22 04:08
import django.db.models.deletion
from django.db import migrations, models

23
trades/mixins.py Normal file
View file

@ -0,0 +1,23 @@
from cards.models import Card
class TradeOfferContextMixin:
def get_context_data(self, **kwargs):
# Start with any context passed in.
context = kwargs.copy()
# Include available cards requirements for multiselect fields.
context.setdefault("cards", Card.objects.all().order_by("name", "rarity_level"))
# Provide friend_codes and selected_friend_code as in TradeOfferCreateView
friend_codes = self.request.user.friend_codes.all()
context["friend_codes"] = friend_codes
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["selected_friend_code"] = selected_friend_code
return context

View file

@ -1,13 +1,16 @@
from django.db import models
from django.core.exceptions import ValidationError
from django.db.models import Q, Count, Prefetch, F, Sum
from django.db.models import Q, Count, Prefetch, F, Sum, Max
import hashlib
from cards.models import Card
from accounts.models import FriendCode
from datetime import timedelta
from django.utils import timezone
class TradeOfferManager(models.Manager):
def get_queryset(self):
queryset = super().get_queryset().select_related(
qs = super().get_queryset().select_related(
"initiated_by__user",
).prefetch_related(
"trade_offer_have_cards__card",
@ -16,8 +19,11 @@ class TradeOfferManager(models.Manager):
"acceptances__requested_card",
"acceptances__offered_card",
"acceptances__accepted_by__user",
).order_by("-updated_at")
return queryset
)
cutoff = timezone.now() - timedelta(days=28)
qs = qs.filter(created_at__gte=cutoff)
return qs.order_by("-updated_at")
class TradeOffer(models.Model):
objects = TradeOfferManager()
@ -57,6 +63,29 @@ class TradeOffer(models.Model):
self.hash = hashlib.md5((str(self.id) + "z").encode("utf-8")).hexdigest()[:8] + "z"
super().save(update_fields=["hash"])
def update_rarity_fields(self):
"""
Recalculates and updates the rarity_level and rarity_icon fields based on
the associated have_cards and want_cards.
Enforces that all cards in the trade offer share the same rarity.
Uses the first card's rarity details to update both fields.
"""
# Gather all cards from both sides.
cards = list(self.have_cards.all()) + list(self.want_cards.all())
if not cards:
return
# Enforce same rarity across all cards.
rarity_levels = {card.rarity_level for card in cards}
if len(rarity_levels) > 1:
raise ValidationError("All cards in a trade offer must have the same rarity.")
first_card = cards[0]
if self.rarity_level != first_card.rarity_level or self.rarity_icon != first_card.rarity_icon:
self.rarity_level = first_card.rarity_level
self.rarity_icon = first_card.rarity_icon
# Use super().save() here to avoid recursion.
super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"])
class TradeOfferHaveCard(models.Model):
"""
Through model for TradeOffer.have_cards.
@ -70,12 +99,20 @@ class TradeOfferHaveCard(models.Model):
)
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True)
quantity = models.PositiveIntegerField(default=1)
# New field to track number of accepted cards for this entry.
qty_accepted = models.PositiveIntegerField(default=0, editable=False)
def __str__(self):
return f"{self.card.name} x{self.quantity} (Accepted: {self.qty_accepted})"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.trade_offer.update_rarity_fields()
def delete(self, *args, **kwargs):
trade_offer = self.trade_offer
super().delete(*args, **kwargs)
trade_offer.update_rarity_fields()
class Meta:
unique_together = ("trade_offer", "card")
@ -91,12 +128,20 @@ class TradeOfferWantCard(models.Model):
)
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(default=1)
# New field for tracking accepted count.
qty_accepted = models.PositiveIntegerField(default=0, editable=False)
def __str__(self):
return f"{self.card.name} x{self.quantity} (Accepted: {self.qty_accepted})"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.trade_offer.update_rarity_fields()
def delete(self, *args, **kwargs):
trade_offer = self.trade_offer
super().delete(*args, **kwargs)
trade_offer.update_rarity_fields()
class Meta:
unique_together = ("trade_offer", "card")

View file

@ -1,45 +1,9 @@
from django.core.exceptions import ValidationError
from django.db.models.signals import m2m_changed, post_save, post_delete, pre_save
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import TradeOffer
from cards.models import Card
from django.db.models import F
from trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance
from django.db import transaction
def validate_and_set_trade_offer_rarity(instance):
"""
Ensures all cards on both sides share the same rarity and sets the TradeOffer's
rarity_level and rarity_icon if they haven't been set already.
"""
combined_cards = list(instance.have_cards.all()) + list(instance.want_cards.all())
if not combined_cards:
return
rarities = {card.rarity_level for card in combined_cards}
if len(rarities) > 1:
raise ValidationError("All cards in a trade offer must have the same rarity.")
updated_fields = []
if instance.rarity_level is None:
instance.rarity_level = combined_cards[0].rarity_level
updated_fields.append("rarity_level")
if instance.rarity_icon is None:
instance.rarity_icon = combined_cards[0].rarity_icon
updated_fields.append("rarity_icon")
if updated_fields:
instance.save(update_fields=updated_fields)
@receiver(m2m_changed, sender=TradeOffer.have_cards.through)
def validate_have_cards_rarity(sender, instance, action, **kwargs):
if action == "post_add":
transaction.on_commit(lambda: validate_and_set_trade_offer_rarity(instance))
@receiver(m2m_changed, sender=TradeOffer.want_cards.through)
def validate_want_cards_rarity(sender, instance, action, **kwargs):
if action == "post_add":
transaction.on_commit(lambda: validate_and_set_trade_offer_rarity(instance))
ACTIVE_STATES = [
TradeAcceptance.AcceptanceState.ACCEPTED,
TradeAcceptance.AcceptanceState.SENT,
@ -83,40 +47,32 @@ def update_trade_offer_closed_status(trade_offer):
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
# Post-save signal to adjust qty_accepted incrementally.
@receiver(post_save, sender=TradeAcceptance)
def trade_acceptance_post_save(sender, instance, created, **kwargs):
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)
def trade_acceptance_post_delete(sender, instance, **kwargs):
if instance.state in ACTIVE_STATES:
@ -124,4 +80,4 @@ def trade_acceptance_post_delete(sender, instance, **kwargs):
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)
update_trade_offer_closed_status(trade_offer)

View file

@ -3,7 +3,7 @@ from django import template
register = template.Library()
@register.inclusion_tag('templatetags/trade_offer.html', takes_context=True)
def render_trade_offer(context, offer, screenshot_mode=False):
def render_trade_offer(context, offer, screenshot_mode=False, show_friend_code=False, expanded=False):
"""
Renders a trade offer including detailed trade acceptance information.
Freezes the through-model querysets to avoid extra DB hits.
@ -26,6 +26,7 @@ def render_trade_offer(context, offer, screenshot_mode=False):
return {
'offer_pk': offer.pk,
'expanded': expanded,
'offer_hash': offer.hash,
'rarity_icon': offer.rarity_icon,
'initiated_by_email': offer.initiated_by.user.email,
@ -36,6 +37,7 @@ def render_trade_offer(context, offer, screenshot_mode=False):
'screenshot_mode': screenshot_mode,
'in_game_name': offer.initiated_by.in_game_name,
'friend_code': offer.initiated_by.friend_code,
'show_friend_code': show_friend_code,
'num_cards_available': len(have_cards_available) + len(want_cards_available),
}

View file

@ -1,7 +1,8 @@
from django.urls import path
from django.views.decorators.cache import cache_page
from .views import (
TradeOfferCreateView,
TradeOfferCreateConfirmView,
TradeOfferAllListView,
TradeOfferMyListView,
TradeOfferDetailView,
@ -14,11 +15,12 @@ from .views import (
urlpatterns = [
path("create/", TradeOfferCreateView.as_view(), name="trade_offer_create"),
path("create/confirm/", TradeOfferCreateConfirmView.as_view(), name="trade_offer_confirm_create"),
path("all/", TradeOfferAllListView.as_view(), name="trade_offer_list"),
path("my/", TradeOfferMyListView.as_view(), name="trade_offer_my_list"),
path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"),
path("<int:pk>/", TradeOfferDetailView.as_view(), name="trade_offer_detail"),
path("<int:pk>.png", TradeOfferPNGView.as_view(), name="trade_offer_png"),
path("<int:pk>.png", cache_page(15)(TradeOfferPNGView.as_view()), name="trade_offer_png"),
path("delete/<int:pk>/", TradeOfferDeleteView.as_view(), name="trade_offer_delete"),
path("offer/<int:offer_pk>", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"),
path("accept/<int:pk>/", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"),

View file

@ -12,7 +12,7 @@ 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 django.views.decorators.cache import cache_page
from meta.views import Meta
from .models import TradeOffer, TradeAcceptance
from .forms import (TradeOfferAcceptForm,
@ -27,6 +27,7 @@ from playwright.sync_api import sync_playwright
from django.conf import settings
class TradeOfferCreateView(LoginRequiredMixin, CreateView):
http_method_names = ['get'] # restricts this view to GET only
model = TradeOffer
form_class = TradeOfferCreateForm
template_name = "trades/trade_offer_create.html"
@ -63,13 +64,6 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
context["selected_friend_code"] = selected_friend_code
return context
def form_valid(self, form):
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 TradeOfferAllListView(ListView):
model = TradeOffer
template_name = "trades/trade_offer_all_list.html"
@ -326,17 +320,16 @@ class TradeOfferSearchView(ListView):
#@silk_profile(name="Trade Offer Search- Get Queryset")
def get_queryset(self):
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()
# 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"))
have_selections = self.parse_selections(self.request.POST.getlist("have_cards"))
want_selections = self.parse_selections(self.request.POST.getlist("want_cards"))
# If no selections are provided, return an empty queryset.
if not offered_selections and not wanted_selections:
if not have_selections and not want_selections:
return TradeOffer.objects.none()
qs = TradeOffer.objects.filter(
@ -344,16 +337,16 @@ class TradeOfferSearchView(ListView):
).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:
if have_selections:
for card_id, qty in have_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:
if want_selections:
for card_id, qty in want_selections:
qs = qs.filter(
trade_offer_have_cards__card_id=card_id,
trade_offer_have_cards__quantity__gte=qty,
@ -373,11 +366,11 @@ class TradeOfferSearchView(ListView):
# Populate available_cards to re-populate the multiselects.
context["cards"] = Card.objects.all().order_by("name")
if self.request.method == "POST":
context["offered_cards"] = self.request.POST.getlist("offered_cards")
context["wanted_cards"] = self.request.POST.getlist("wanted_cards")
context["have_cards"] = self.request.POST.getlist("have_cards")
context["want_cards"] = self.request.POST.getlist("want_cards")
else:
context["offered_cards"] = []
context["wanted_cards"] = []
context["have_cards"] = []
context["want_cards"] = []
return context
#@silk_profile(name="Trade Offer Search- Render to Response")
@ -405,6 +398,8 @@ class TradeOfferDetailView(DetailView):
context = super().get_context_data(**kwargs)
trade_offer = self.get_object()
screenshot_mode = self.request.GET.get("screenshot_mode")
if screenshot_mode:
context["show_friend_code"] = trade_offer.initiated_by.user.show_friend_code_on_link_previews
context["screenshot_mode"] = screenshot_mode
# Calculate the number of cards in each category.
@ -418,14 +413,14 @@ class TradeOfferDetailView(DetailView):
# Calculate a base height using our previous assumptions:
# - 80px per card row (with rows computed as round(num_cards/2))
# - plus 138px for header/footer.
base_height = (round(num_cards / 2) * 80) + 138
base_height = (round(num_cards / 2) * 56) + 138
# Calculate a base width by assuming two columns of card badges.
# Here we assume each card badge is 80px wide plus the same horizontal offset of 138px.
if (num_wants + num_has) >= 3:
base_width = ((num_wants + num_has) * 144) + 96
else:
if (num_wants + num_has) >= 4:
base_width = (4 * 144) + 96
else:
base_width = (2 * 144) + 128
if base_height > base_width:
# The trade-offer card is taller than wide;
@ -610,6 +605,7 @@ class TradeOfferPNGView(View):
This view loads the trade offer detail page, waits for the trade offer element to render,
simulates a click to expand extra badges, and then screenshots only the trade offer element.
"""
def get(self, request, *args, **kwargs):
# For demonstration purposes, get the first trade offer.
# In production, you might want to identify the offer via a URL parameter.
@ -682,3 +678,174 @@ class TradeOfferPNGView(View):
browser.close()
return HttpResponse(png_bytes, content_type="image/png")
class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
"""
Processes a two-step create for TradeOffer; on confirmation,
commits the offer and shows form errors if any occur.
"""
def post(self, request, *args, **kwargs):
if "confirm" in request.POST:
return self._commit_offer(request)
elif "edit" in request.POST:
return self._redirect_to_edit(request)
elif "preview" in request.POST:
return self._preview_offer(request)
else:
return self._preview_offer(request)
def _commit_offer(self, request):
"""
Commits the offer after confirmation. Any model ValidationError (for example,
due to mismatched card rarities) is caught and added to the form errors so that
it shows up in trade_offer_create.html.
"""
# Instantiate the form with POST data.
form = TradeOfferCreateForm(request.POST)
# Ensure that the 'initiated_by' queryset is limited to the user's friend codes.
form.fields["initiated_by"].queryset = request.user.friend_codes.all()
if form.is_valid():
try:
trade_offer = form.save()
except ValidationError as error:
form.add_error(None, error)
# Update the form's initial data so the template can safely reference have_cards/want_cards.
form.initial = {
"have_cards": request.POST.getlist("have_cards"),
"want_cards": request.POST.getlist("want_cards"),
"initiated_by": request.POST.get("initiated_by"),
}
# Supply additional context required by trade_offer_create.html.
from cards.models import Card
context = {
"form": form,
"friend_codes": request.user.friend_codes.all(),
"selected_friend_code": (
request.user.default_friend_code or request.user.friend_codes.first()
),
"cards": Card.objects.all().order_by("name", "rarity_level"),
}
return render(request, "trades/trade_offer_create.html", context)
messages.success(request, "Trade offer created successfully!")
return HttpResponseRedirect(reverse_lazy("trade_offer_list"))
else:
# When the form is not valid, update its initial data as well:
form.initial = {
"have_cards": request.POST.getlist("have_cards"),
"want_cards": request.POST.getlist("want_cards"),
"initiated_by": request.POST.get("initiated_by"),
}
from cards.models import Card
context = {
"form": form,
"friend_codes": request.user.friend_codes.all(),
"selected_friend_code": (
request.user.default_friend_code or request.user.friend_codes.first()
),
"cards": Card.objects.all().order_by("name", "rarity_level"),
}
return render(request, "trades/trade_offer_create.html", context)
def _redirect_to_edit(self, request):
query_params = request.POST.copy()
query_params.pop("csrfmiddlewaretoken", None)
query_params.pop("edit", None)
query_params.pop("confirm", None)
query_params.pop("preview", None)
from django.urls import reverse
base_url = reverse("trade_offer_create")
url_with_params = f"{base_url}?{query_params.urlencode()}"
return HttpResponseRedirect(url_with_params)
def _preview_offer(self, request):
"""
Processes the preview action (existing logic remains unchanged).
"""
form = TradeOfferCreateForm(request.POST)
form.fields["initiated_by"].queryset = request.user.friend_codes.all()
if not form.is_valid():
# Re-render the creation template with errors.
return render(request, "trades/trade_offer_create.html", {"form": form})
# Parse the card selections for "have" and "want" cards.
have_selections = self._parse_card_selections("have_cards")
want_selections = self._parse_card_selections("want_cards")
from cards.models import Card
have_cards_ids = [card_id for card_id, _ in have_selections]
cards_have_qs = Card.objects.filter(pk__in=have_cards_ids)
cards_have_dict = {card.pk: card for card in cards_have_qs}
# Define a dummy wrapper for a trade offer card entry.
class DummyOfferCard:
def __init__(self, card, quantity):
self.card = card
self.quantity = quantity
self.qty_accepted = 0
have_offer_cards = []
for card_id, quantity in have_selections:
card = cards_have_dict.get(card_id)
if card:
have_offer_cards.append(DummyOfferCard(card, quantity))
want_cards_ids = [card_id for card_id, _ in want_selections]
cards_want_qs = Card.objects.filter(pk__in=want_cards_ids)
cards_want_dict = {card.pk: card for card in cards_want_qs}
want_offer_cards = []
for card_id, quantity in want_selections:
card = cards_want_dict.get(card_id)
if card:
want_offer_cards.append(DummyOfferCard(card, quantity))
# Mimic a related manager's all() method.
class DummyManager:
def __init__(self, items):
self.items = items
def all(self):
return self.items
# Create a dummy TradeOffer object with properties required by the render_trade_offer tag.
class DummyTradeOffer:
pass
dummy_trade_offer = DummyTradeOffer()
dummy_trade_offer.pk = 0 # a placeholder primary key
dummy_trade_offer.hash = "preview"
dummy_trade_offer.rarity_icon = ""
dummy_trade_offer.trade_offer_have_cards = DummyManager(have_offer_cards)
dummy_trade_offer.trade_offer_want_cards = DummyManager(want_offer_cards)
dummy_trade_offer.acceptances = DummyManager([]) # no acceptances in preview
dummy_trade_offer.initiated_by = form.cleaned_data["initiated_by"]
# Pass along the POST data so that hidden inputs can be re-generated.
context = {
"dummy_trade_offer": dummy_trade_offer,
"post_data": request.POST,
}
return render(request, "trades/trade_offer_confirm_create.html", context)
def _parse_card_selections(self, key):
"""
Parses card selections from POST data for a given key (e.g., 'have_cards' or 'want_cards').
Selections are expected in the format 'card_id:quantity', defaulting quantity to 1 if missing.
Returns a list of (card_id, quantity) tuples.
"""
selections = self.request.POST.getlist(key)
results = []
for selection in selections:
parts = selection.split(":")
try:
card_id = int(parts[0])
except (ValueError, IndexError):
continue
quantity = 1
if len(parts) > 1:
try:
quantity = int(parts[1])
except ValueError:
pass
results.append((card_id, quantity))
return results