Refactor card badge and multiselect template tags to properly implement and/or improve caching and context handling

- Updated `card_badge` and `card_multiselect` template tags to utilize `reverse_lazy` for URL resolution.
- Enhanced caching mechanisms in `card_badge.html` and `card_multiselect.html` to improve performance.
- Introduced a new template `_card_multiselect_options.html` for rendering multiselect options.
- Improved context management in `card_multiselect` to handle selected cards and dynamic placeholders.
- Added error handling for query hashing in `card_multiselect` to ensure robustness.
- Updated `trade_offer_tags` to optimize database queries using `select_related` for related objects.
This commit is contained in:
badblocks 2025-04-29 13:50:52 -07:00
parent 7d94dc001f
commit 4e50e1545c
10 changed files with 234 additions and 163 deletions

View file

@ -1,41 +1,46 @@
from django import template from django import template
from django.conf import settings
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.urls import reverse from django.urls import reverse_lazy
register = template.Library() register = template.Library()
@register.inclusion_tag("templatetags/card_badge.html") @register.inclusion_tag("templatetags/card_badge.html", takes_context=True)
def card_badge(card, quantity=None, expanded=False): def card_badge(context, card, quantity=None, expanded=False):
url = reverse('cards:card_detail', args=[card.pk]) """
return { Renders a card badge.
"""
url = reverse_lazy('cards:card_detail', args=[card.pk])
tag_context = {
'quantity': quantity, 'quantity': quantity,
'style': card.style, 'style': card.style,
'name': card.name, 'name': card.name,
'rarity': card.rarity_icon, 'rarity': card.rarity_icon,
'cardset': card.cardset, 'cardset': card.cardset,
'expanded': expanded, 'expanded': expanded,
'cache_key': f'card_badge_{card.pk}_{quantity}_{expanded}',
'url': url, 'url': url,
} }
context.update(tag_context)
return context
@register.filter @register.filter
def card_badge_inline(card, quantity=None): def card_badge_inline(card, quantity=None):
""" """
Renders an inline card badge. Renders an inline card badge by directly rendering the template.
""" """
url = reverse('cards:card_detail', args=[card.pk]) url = reverse_lazy('cards:card_detail', args=[card.pk])
html = render_to_string("templatetags/card_badge.html", { tag_context = {
'quantity': quantity, 'quantity': quantity,
'style': card.style, 'style': card.style,
'name': card.name, 'name': card.name,
'rarity': card.rarity_icon, 'rarity': card.rarity_icon,
'cardset': card.cardset, 'cardset': card.cardset,
'expanded': True, 'expanded': True,
'cache_key': f'card_badge_{card.pk}_{quantity}_{True}',
'CACHE_TIMEOUT': settings.CACHE_TIMEOUT,
'url': url, 'url': url,
}) }
html = render_to_string("templatetags/card_badge.html", tag_context)
return mark_safe(html) return mark_safe(html)
@register.filter
def addstr(arg1, arg2):
"""concatenate arg1 & arg2"""
return str(arg1) + str(arg2)

View file

@ -1,54 +1,72 @@
import uuid
from django import template from django import template
from cards.models import Card from cards.models import Card
from django.db.models.query import QuerySet
import json
import hashlib
import logging
register = template.Library() register = template.Library()
@register.inclusion_tag('templatetags/card_multiselect.html') @register.filter
def card_multiselect(field_name, label, placeholder, cards=None, selected_values=None, cache_timeout=86400): def get_item(dictionary, key):
"""Allows accessing dictionary items using a variable key in templates."""
return dictionary.get(key)
@register.simple_tag
def fetch_all_cards():
"""Simple tag to fetch all Card objects."""
return Card.objects.order_by('pk').all()
@register.inclusion_tag('templatetags/card_multiselect.html', takes_context=True)
def card_multiselect(context, field_name, label, placeholder, cards=None, selected_values=None):
""" """
Renders a multiselect field for choosing cards while supporting quantity data. Prepares context for rendering a card multiselect input.
Database querying and rendering are handled within the template's cache block.
Updated to allow `card_filter` to be either a dictionary (of lookup parameters) or a QuerySet.
This is useful when you want to limit available cards based on your new trades models (e.g. showing only
cards that appear in active trade offers).
Parameters:
- field_name: The name attribute for the select tag.
- label: Label text to show above the selector.
- placeholder: Placeholder text to show in the select.
- selected_values: (Optional) A list of selected values; if a value includes a quantity it should be in the format "card_id:quantity".
- cache_timeout: (Optional) Cache timeout (in seconds) for the options block.
- cache_key: (Optional) Cache key.
""" """
if selected_values is None: if selected_values is None:
selected_values = [] selected_values = []
# Create a mapping {card_id: quantity}
selected_cards = {} selected_cards = {}
for val in selected_values: for val in selected_values:
parts = str(val).split(':') parts = str(val).split(':')
if len(parts) >= 1 and parts[0]:
card_id = parts[0] card_id = parts[0]
quantity = parts[1] if len(parts) > 1 else 1 quantity = parts[1] if len(parts) > 1 else 1
selected_cards[card_id] = quantity selected_cards[str(card_id)] = quantity
if cards is None: effective_field_name = field_name if field_name is not None else 'card_multiselect'
cards = Card.objects.all() effective_label = label if label is not None else 'Card'
effective_placeholder = placeholder if placeholder is not None else 'Select Cards'
# Loop through available cards and attach preselected quantity selected_cards_key_part = json.dumps(selected_cards, sort_keys=True)
for card in cards:
pk_str = str(card.pk) has_passed_cards = isinstance(cards, QuerySet)
if pk_str in selected_cards:
card.selected_quantity = selected_cards[pk_str] if has_passed_cards:
card.selected = True try:
query_string = str(cards.query)
passed_cards_identifier = hashlib.sha256(query_string.encode('utf-8')).hexdigest()
except Exception as e:
logging.warning(f"Could not generate query hash for card_multiselect. Error: {e}")
passed_cards_identifier = 'specific_qs_fallback_' + str(uuid.uuid4())
else: else:
card.selected_quantity = 1 passed_cards_identifier = 'all_cards'
card.selected = False
return { # Define the variables specific to this tag
'field_name': field_name, tag_specific_context = {
'field_id': field_name, # using the name as id for simplicity 'field_name': effective_field_name,
'label': label, 'field_id': effective_field_name,
'cards': cards, 'label': effective_label,
'placeholder': placeholder, 'placeholder': effective_placeholder,
'selected_values': list(selected_cards.keys()), 'passed_cards': cards if has_passed_cards else None,
'cache_timeout': cache_timeout 'has_passed_cards': has_passed_cards,
'selected_cards': selected_cards,
'selected_cards_key_part': selected_cards_key_part,
'passed_cards_identifier': passed_cards_identifier,
} }
# Update the original context with the tag-specific variables
# This preserves CACHE_TIMEOUT and other parent context variables
context.update(tag_specific_context)
return context # Return the MODIFIED original context

View file

@ -0,0 +1,30 @@
{% load card_badge card_multiselect %}
<option value="" disabled>{{ placeholder }}</option>
{% for card in cards_to_render %}
{% with card_id_str=card.pk|stringformat:"s" %} {# Ensure card PK is string for lookup #}
{% if card_id_str in selected_cards %}
<option
value="{{ card.pk }}:{{ selected_cards|get_item:card_id_str }}"
data-card-id="{{ card.pk }}"
data-quantity="{{ selected_cards|get_item:card_id_str }}"
selected
data-html-content='<div class="m-2">{{ card|card_badge_inline:selected_cards|get_item:card_id_str }}</div>'
data-name="{{ card.name }}"
data-rarity="{{ card.rarity_icon }}"
data-cardset="{{ card.cardset }}">
{{ card.name }} {{ card.rarity_icon }} {{ card.cardset }}
</option>
{% else %}
<option
value="{{ card.pk }}:1"
data-card-id="{{ card.pk }}"
data-quantity="1"
data-html-content='<div class="m-2">{{ card|card_badge_inline:"" }}</div>'
data-name="{{ card.name }}"
data-rarity="{{ card.rarity_icon }}"
data-cardset="{{ card.cardset }}">
{{ card.name }} {{ card.rarity_icon }} {{ card.cardset }}
</option>
{% endif %}
{% endwith %}
{% endfor %}

View file

@ -1,3 +1,5 @@
{% load cache %}
{% cache CACHE_TIMEOUT card_badge cache_key %}
<a href="{{ url }}" @click.stop> <a href="{{ url }}" @click.stop>
<div class="relative block"> <div class="relative block">
{% if not expanded %} {% if not expanded %}
@ -24,3 +26,4 @@
{% endif %} {% endif %}
</div> </div>
</a> </a>
{% endcache %}

View file

@ -1,22 +1,16 @@
{% load cache card_badge %} {% load cache card_badge %}
{% load cache card_multiselect %}
<label for="{{ field_id }}" class="label"> <label for="{{ field_id }}" class="label">
<span class="label-text">{{ label }}</span> <span class="label-text">{{ label }}</span>
</label> </label>
<select name="{{ field_name }}" id="{{ field_id }}" class="select select-bordered w-full card-multiselect" data-placeholder="{{ placeholder }}" multiple x-cloak> <select name="{{ field_name }}" id="{{ field_id }}" class="select select-bordered w-full card-multiselect" data-placeholder="{{ placeholder }}" multiple x-cloak>
{% cache cache_timeout card_multiselect selected_values|join:"," %} {% cache CACHE_TIMEOUT card_multiselect field_name label placeholder passed_cards_identifier selected_cards_key_part %}
<option value="" disabled>{{ placeholder }}</option> {% if has_passed_cards %}
{% for card in cards %} {% include "templatetags/_card_multiselect_options.html" with cards_to_render=passed_cards selected_cards=selected_cards placeholder=placeholder %}
<option {% else %}
value="{{ card.pk }}" {% fetch_all_cards as all_db_cards %}
data-card-id="{{ card.pk }}" {% include "templatetags/_card_multiselect_options.html" with cards_to_render=all_db_cards selected_cards=selected_cards placeholder=placeholder %}
data-quantity="{{ card.selected_quantity }}" {% endif %}
{% if card.selected %}selected{% endif %}
data-html-content='<div class="m-2">{{ card|card_badge_inline:"__QUANTITY__" }}</div>'
data-name="{{ card.name }}"
data-rarity="{{ card.rarity_icon }}"
data-cardset="{{ card.cardset }}">
{{ card.name }} {{ card.rarity_icon }} {{ card.cardset }}
</option>
{% endfor %}
{% endcache %} {% endcache %}
</select> </select>

View file

@ -1,5 +1,6 @@
{% load gravatar card_badge %} {% load gravatar card_badge %}
{% cache CACHE_TIMEOUT trade_acceptance cache_key %}
<div class="card card-border bg-base-100 shadow-lg max-w-90 mx-auto"> <div class="card card-border bg-base-100 shadow-lg max-w-90 mx-auto">
<!-- Header --> <!-- Header -->
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}"> <a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}">
@ -56,3 +57,4 @@
</div> </div>
</a> </a>
</div> </div>
{% endcache %}

View file

@ -1,6 +1,6 @@
{% load gravatar card_badge cache %} {% load gravatar card_badge cache %}
{% cache 60 trade_offer offer_pk %} {% cache CACHE_TIMEOUT trade_offer cache_key %}
<div x-data="{ flipped: {{flipped|lower}}, offerExpanded: {{flipped|yesno:'false,true'}}, acceptanceExpanded: {{flipped|lower}} }" x-ref="tradeOffer" class="transition-all duration-500 trade-offer-card"> <div x-data="{ flipped: {{flipped|lower}}, offerExpanded: {{flipped|yesno:'false,true'}}, acceptanceExpanded: {{flipped|lower}} }" x-ref="tradeOffer" class="transition-all duration-500 trade-offer-card">
<div class="flip-container"> <div class="flip-container">
<div class="flip-inner grid grid-cols-1 grid-rows-1 card bg-base-100 card-border shadow-lg w-90 transform transition-transform duration-500 ease-in-out{%if flipped %} rotate-y-180{% endif %}" <div class="flip-inner grid grid-cols-1 grid-rows-1 card bg-base-100 card-border shadow-lg w-90 transform transition-transform duration-500 ease-in-out{%if flipped %} rotate-y-180{% endif %}"

View file

@ -5,6 +5,5 @@ class TradesConfig(AppConfig):
name = "trades" name = "trades"
def ready(self): def ready(self):
# This import registers the signal handlers defined in trades/signals.py, # Implicitly connect signal handlers decorated with @receiver.
# ensuring that denormalized field updates occur whenever related objects change.
import trades.signals import trades.signals

View file

@ -1,7 +1,7 @@
from django.db.models.signals import post_save, post_delete, pre_save from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.db.models import F from django.db.models import F
from trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance from trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance, TradeOffer
from django.db import transaction from django.db import transaction
from accounts.models import CustomUser from accounts.models import CustomUser
from datetime import timedelta from datetime import timedelta
@ -12,6 +12,8 @@ from django.core.mail import send_mail
from django.conf import settings from django.conf import settings
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.cache import cache
import logging
POSITIVE_STATES = [ POSITIVE_STATES = [
TradeAcceptance.AcceptanceState.ACCEPTED, TradeAcceptance.AcceptanceState.ACCEPTED,
@ -260,3 +262,17 @@ def trade_acceptance_reputation_delete(sender, instance, **kwargs):
CustomUser.objects.filter(pk=instance.trade_offer.initiated_by.user.pk).update( CustomUser.objects.filter(pk=instance.trade_offer.initiated_by.user.pk).update(
reputation_score=F("reputation_score") + 1 reputation_score=F("reputation_score") + 1
) )
@receiver(post_save, sender=TradeOfferHaveCard)
@receiver(post_delete, sender=TradeOfferHaveCard)
@receiver(post_save, sender=TradeOfferWantCard)
@receiver(post_delete, sender=TradeOfferWantCard)
@receiver(post_save, sender=TradeAcceptance)
@receiver(post_delete, sender=TradeAcceptance)
def bubble_up_trade_offer_updates(sender, instance, **kwargs):
"""
Bubble up updates to the TradeOffer model when TradeOfferHaveCard, TradeOfferWantCard,
or TradeAcceptance instances are created, updated, or deleted.
"""
if instance.trade_offer:
instance.trade_offer.save(update_fields=['updated_at'])

View file

@ -9,8 +9,10 @@ def render_trade_offer(context, offer):
Renders a trade offer including detailed trade acceptance information. Renders a trade offer including detailed trade acceptance information.
Freezes the through-model querysets to avoid extra DB hits. Freezes the through-model querysets to avoid extra DB hits.
""" """
trade_offer_have_cards = list(offer.trade_offer_have_cards.all()) trade_offer_have_cards = list(offer.trade_offer_have_cards.select_related('card').all())
trade_offer_want_cards = list(offer.trade_offer_want_cards.all()) trade_offer_want_cards = list(offer.trade_offer_want_cards.select_related('card').all())
acceptances = list(offer.acceptances.select_related('accepted_by__user', 'requested_card', 'offered_card').all())
have_cards_available = [ have_cards_available = [
card for card in trade_offer_have_cards card for card in trade_offer_have_cards
@ -21,18 +23,14 @@ def render_trade_offer(context, offer):
if card.quantity > card.qty_accepted if card.quantity > card.qty_accepted
] ]
acceptances = list(offer.acceptances.all())
# Determine if the offer should show its back side (acceptances view) by default.
# If either side has no available cards, then flip the offer.
if not have_cards_available or not want_cards_available: if not have_cards_available or not want_cards_available:
flipped = True flipped = True
else: else:
flipped = False flipped = False
return { tag_context = {
'offer_pk': offer.pk, 'offer_pk': offer.pk,
'flipped': flipped, # new flag to control the default face 'flipped': flipped,
'offer_hash': offer.hash, 'offer_hash': offer.hash,
'rarity_icon': offer.rarity_icon, 'rarity_icon': offer.rarity_icon,
'initiated_by_email': offer.initiated_by.user.email, 'initiated_by_email': offer.initiated_by.user.email,
@ -43,19 +41,25 @@ def render_trade_offer(context, offer):
'want_cards_available': want_cards_available, 'want_cards_available': want_cards_available,
'num_cards_available': len(have_cards_available) + len(want_cards_available), 'num_cards_available': len(have_cards_available) + len(want_cards_available),
'on_detail_page': context.get("request").path.endswith("trades/"+str(offer.pk)+"/"), 'on_detail_page': context.get("request").path.endswith("trades/"+str(offer.pk)+"/"),
'cache_key': f'trade_offer_{offer.pk}_{offer.updated_at.timestamp()}_{flipped}',
} }
context.update(tag_context)
return context
@register.inclusion_tag('templatetags/trade_acceptance.html', takes_context=True) @register.inclusion_tag('templatetags/trade_acceptance.html', takes_context=True)
def render_trade_acceptance(context, acceptance): def render_trade_acceptance(context, acceptance):
""" """
Renders a simple trade acceptance view with a single row and simplified header/footer. Renders a simple trade acceptance view with a single row and simplified header/footer.
""" """
tag_context = {
return {
"acceptance": acceptance, "acceptance": acceptance,
"request": context.get("request"), 'cache_key': f'trade_acceptance_{acceptance.pk}_{acceptance.updated_at.timestamp()}',
} }
context.update(tag_context)
return context
@register.filter @register.filter
def get_action_label(acceptance, state_value): def get_action_label(acceptance, state_value):
""" """