Refactor database models to majorly increase queries needed and decrease load times of home from 30 secs to 5 sec (we will be caching the rest to decrease even further via background tasks)

This commit is contained in:
badblocks 2025-03-17 14:08:01 -07:00
parent f7a9b2f823
commit 86c7eba10a
25 changed files with 1941 additions and 1560 deletions

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-16 18:18
# Generated by Django 5.1.2 on 2025-03-17 20:39
import django.contrib.auth.models
import django.contrib.auth.validators

View file

@ -1,9 +1,7 @@
from django.contrib import admin
from .models import CardSet, Deck, Card, Rarity, DeckNameTranslation, CardNameTranslation
from .models import Deck, Card, DeckNameTranslation, CardNameTranslation
admin.site.register(CardSet)
admin.site.register(Deck)
admin.site.register(Card)
admin.site.register(Rarity)
admin.site.register(DeckNameTranslation)
admin.site.register(CardNameTranslation)

View file

@ -3,3 +3,6 @@ from django.apps import AppConfig
class CardsConfig(AppConfig):
name = "cards"
def ready(self):
import cards.signals

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-16 18:18
# Generated by Django 5.1.2 on 2025-03-17 20:39
import django.db.models.deletion
from django.db import migrations, models
@ -16,28 +16,23 @@ class Migration(migrations.Migration):
name='Card',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('name', models.CharField(max_length=128)),
('cardset', models.CharField(max_length=8)),
('cardnum', models.IntegerField()),
('style', models.CharField(max_length=255)),
('rarity_icon', models.CharField(max_length=8)),
('rarity_level', models.IntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='CardSet',
name='Deck',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='Rarity',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('normalized_id', models.IntegerField()),
('name', models.CharField(max_length=64)),
('icons', models.CharField(max_length=64)),
('hex_color', models.CharField(max_length=9)),
('cardset', models.CharField(max_length=8)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
@ -53,22 +48,6 @@ class Migration(migrations.Migration):
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.card')),
],
),
migrations.AddField(
model_name='card',
name='cardset',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cards', to='cards.cardset'),
),
migrations.CreateModel(
name='Deck',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('hex_color', models.CharField(max_length=9)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cardset', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='decks', to='cards.cardset')),
],
),
migrations.AddField(
model_name='card',
name='decks',
@ -85,20 +64,4 @@ class Migration(migrations.Migration):
('deck', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.deck')),
],
),
migrations.AddField(
model_name='card',
name='rarity',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cards', to='cards.rarity'),
),
migrations.CreateModel(
name='RarityNameTranslation',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('language', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('rarity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.rarity')),
],
),
]

View file

@ -23,91 +23,29 @@ class CardNameTranslation(models.Model):
def __str__(self):
return self.name
class RarityNameTranslation(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
rarity = models.ForeignKey("Rarity", on_delete=models.PROTECT, related_name='name_translations')
language = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class CardSet(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class Deck(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
hex_color = models.CharField(max_length=9)
cardset = models.ForeignKey("CardSet", on_delete=models.PROTECT, related_name='decks')
cardset = models.CharField(max_length=8)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class Rarity(models.Model):
id = models.AutoField(primary_key=True)
normalized_id = models.IntegerField(null=False)
name = models.CharField(max_length=64)
icons = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
# Custom Manager for Card model
class CardPrefetchManager(models.Manager):
def get_queryset(self):
return (
super()
.get_queryset()
.select_related("cardset", "rarity")
.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):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
name = models.CharField(max_length=128)
decks = models.ManyToManyField("Deck")
cardset = models.ForeignKey("CardSet", on_delete=models.PROTECT, related_name='cards')
cardset = models.CharField(max_length=8)
cardnum = models.IntegerField()
rarity = models.ForeignKey(Rarity, on_delete=models.PROTECT, related_name='cards')
style = models.CharField(max_length=255, null=False)
rarity_icon = models.CharField(max_length=8)
rarity_level = models.IntegerField()
created_at = models.DateTimeField(auto_now_add=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):
# For display, we show the original rarity icons.
return f"{self.name} {self.rarity.icons} {self.cardset.name}"
@property
def normalized_rarity(self):
"""
Returns the canonical rarity id for trade logic.
"""
return self.rarity.normalized_id
return f"{self.name} {self.rarity_icon} {self.cardset}"

17
cards/signals.py Normal file
View file

@ -0,0 +1,17 @@
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import Card
@receiver(m2m_changed, sender=Card.decks.through)
def update_card_style(sender, instance, action, **kwargs):
if action == "post_add":
decks = instance.decks.all()
num_decks = decks.count()
if num_decks == 1:
instance.style = "background-color: " + decks.first().hex_color + ";"
elif num_decks >= 2:
hex_colors = [deck.hex_color for deck in decks]
instance.style = f"background: linear-gradient(to right, {', '.join(hex_colors)});"
else:
instance.style = "background: linear-gradient(to right, #AAAAAA, #AAAAAA, #AAAAAA);"
instance.save(update_fields=["style"])

View file

@ -6,13 +6,12 @@ register = template.Library()
@register.inclusion_tag("templatetags/card_badge.html")
def card_badge(card, quantity=1):
# Freeze the decks queryset once so that both the iteration and count use the same data
decks = list(card.decks.all()) if card else []
return {
'card': card,
'quantity': quantity,
'decks': decks,
'num_decks': len(decks),
'style': card.style,
'name': card.name,
'rarity': card.rarity_icon,
'cardset': card.cardset,
}
@register.filter
@ -21,9 +20,10 @@ def card_badge_inline(card, quantity=1):
Renders an inline card badge.
"""
html = render_to_string("templatetags/card_badge.html", {
'card': card,
'quantity': quantity,
'decks': list(card.decks.all()) if card else [],
'num_decks': len(list(card.decks.all())) if card else 0,
'style': card.style,
'name': card.name,
'rarity': card.rarity_icon,
'cardset': card.cardset,
})
return mark_safe(html)

View file

@ -47,7 +47,7 @@ INSTALLED_APPS = [
'allauth.socialaccount.providers.google',
"crispy_forms",
"crispy_tailwind",
#"debug_toolbar",
"debug_toolbar",
"el_pagination",
"tailwind",
"theme",
@ -56,7 +56,7 @@ INSTALLED_APPS = [
"cards",
"home",
"trades.apps.TradesConfig",
"silk",
#"silk",
]
SILKY_PYTHON_PROFILER = True
@ -73,13 +73,13 @@ MIDDLEWARE = [
"whitenoise.middleware.WhiteNoiseMiddleware", # WhiteNoise
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
#"debug_toolbar.middleware.DebugToolbarMiddleware", # Django Debug Toolbar
"debug_toolbar.middleware.DebugToolbarMiddleware", # Django Debug Toolbar
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware", # django-allauth
'silk.middleware.SilkyMiddleware',
#'silk.middleware.SilkyMiddleware',
"django_browser_reload.middleware.BrowserReloadMiddleware",
#"django_project.middleware.AutoLoginMiddleware",
]
@ -299,6 +299,6 @@ else:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
"LOCATION": "django_site_cache",
"LOCATION": "site-cache",
}
}

View file

@ -10,7 +10,7 @@ urlpatterns = [
path('account/', include('accounts.urls')),
path("trades/", include("trades.urls")),
path("__reload__/", include("django_browser_reload.urls")),
path('silk/', include('silk.urls', namespace='silk')),
#path('silk/', include('silk.urls', namespace='silk')),
]
if settings.DEBUG:

View file

@ -1,24 +1,24 @@
from collections import defaultdict
from collections import defaultdict, OrderedDict
from django.views.generic import TemplateView
from django.urls import reverse_lazy
from django.db.models import Count, Q, Prefetch, Sum, F, IntegerField, Value, BooleanField, Case, When
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from trades.models import TradeOffer, TradeAcceptance, TradeOfferHaveCard, TradeOfferWantCard
from cards.models import Card, CardSet, Rarity
from cards.models import Card
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from django.template.response import TemplateResponse
from django.http import HttpResponseRedirect
from silk.profiling.profiler import silk_profile
#from silk.profiling.profiler import silk_profile
class HomePageView(TemplateView):
template_name = "home/home.html"
@silk_profile(name='Home Page')
#@silk_profile(name='Home Page')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["cards"] = Card.objects.all().order_by("name", "rarity__pk")
context["cards"] = Card.objects.all().order_by("name")
# Reuse base trade offer queryset for market stats
base_offer_qs = TradeOffer.objects.filter(is_closed=False)
@ -29,46 +29,44 @@ class HomePageView(TemplateView):
# Most Offered Cards
context["most_offered_cards"] = (
Card.objects_no_prefetch.filter(tradeofferhavecard__isnull=False)
Card.objects.filter(tradeofferhavecard__isnull=False)
.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
.order_by("-offer_count")[:6]
)
# Most Wanted Cards
context["most_wanted_cards"] = (
Card.objects_no_prefetch.filter(tradeofferwantcard__isnull=False)
Card.objects.filter(tradeofferwantcard__isnull=False)
.annotate(offer_count=Sum("tradeofferwantcard__quantity"))
.order_by("-offer_count")[:6]
)
# Least Offered Cards
context["least_offered_cards"] = (
Card.objects_no_prefetch.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
Card.objects.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
.order_by("offer_count")[:6]
)
featured = {}
# Featured "All" offers
# Build featured offers with custom ordering
featured = OrderedDict()
# Featured "All" offers remains fixed at the top
featured["All"] = base_offer_qs.order_by("created_at")[:6]
# Get the normalized ids for rarities with pk<=5.
normalized_ids = list(
Rarity.objects.filter(pk__lte=5).values_list("normalized_id", flat=True).distinct()
)
rarity_map = {
rarity.normalized_id: rarity.icons
for rarity in Rarity.objects.filter(pk__lte=5)
}
# For each normalized id (sorted descending), filter base offers that have the matching trade offer rarity.
for norm in sorted(normalized_ids, reverse=True):
offers_qs = base_offer_qs.filter(
rarity__normalized_id=norm # now using trade_offer.rarity
).order_by("created_at").distinct()[:6]
icon_label = rarity_map.get(norm)
if icon_label:
featured[icon_label] = offers_qs
# Pull out distinct (rarity_level, rarity_icon) tuples
distinct_rarities = base_offer_qs.values_list("rarity_level", "rarity_icon").distinct()
# Prepare a list that holds tuples of (rarity_level, rarity_icon, offers)
rarity_offers = []
for rarity_level, rarity_icon in distinct_rarities:
offers = base_offer_qs.filter(rarity_level=rarity_level).order_by("created_at")[:6]
rarity_offers.append((rarity_level, rarity_icon, offers))
# Sort by rarity_level (from greatest to least)
rarity_offers.sort(key=lambda x: x[0], reverse=True)
# Add the sorted offers to the OrderedDict
for rarity_level, rarity_icon, offers in rarity_offers:
featured[rarity_icon] = offers
context["featured_offers"] = featured

View file

@ -1,112 +0,0 @@
[
{
"model": "cards.rarity",
"pk": 1,
"fields": {
"icons": "🔷",
"normalized_id": 1,
"name": "Common",
"created_at": "2025-02-16T06:54:40.993Z",
"updated_at": "2025-02-16T06:54:40.993Z"
}
},
{
"model": "cards.rarity",
"pk": 2,
"fields": {
"icons": "🔷🔷",
"normalized_id": 2,
"name": "Uncommon",
"created_at": "2025-02-16T06:54:44.213Z",
"updated_at": "2025-02-16T06:54:44.213Z"
}
},
{
"model": "cards.rarity",
"pk": 3,
"fields": {
"icons": "🔷🔷🔷",
"normalized_id": 3,
"name": "Rare",
"created_at": "2025-02-16T06:54:47.297Z",
"updated_at": "2025-02-16T06:54:47.297Z"
}
},
{
"model": "cards.rarity",
"pk": 4,
"fields": {
"icons": "🔷🔷🔷🔷",
"normalized_id": 4,
"name": "Double Rare",
"created_at": "2025-02-16T06:54:50.363Z",
"updated_at": "2025-02-16T06:54:50.363Z"
}
},
{
"model": "cards.rarity",
"pk": 5,
"fields": {
"icons": "⭐️",
"normalized_id": 5,
"name": "Full Art Rare",
"created_at": "2025-02-16T06:54:59.888Z",
"updated_at": "2025-02-16T06:54:59.888Z"
}
},
{
"model": "cards.rarity",
"pk": 6,
"fields": {
"icons": "⭐️⭐️",
"normalized_id": 6,
"name": "Super Rare",
"created_at": "2025-02-16T06:55:02.853Z",
"updated_at": "2025-02-16T06:55:02.853Z"
}
},
{
"model": "cards.rarity",
"pk": 7,
"fields": {
"icons": "🌟🌟",
"normalized_id": 6,
"name": "Special Art Rare",
"created_at": "2025-02-16T06:55:02.853Z",
"updated_at": "2025-02-16T06:55:02.853Z"
}
},
{
"model": "cards.rarity",
"pk": 8,
"fields": {
"icons": "⭐️⭐️⭐️",
"normalized_id": 7,
"name": "Immersive Rare",
"created_at": "2025-02-16T06:55:05.728Z",
"updated_at": "2025-02-16T06:55:05.728Z"
}
},
{
"model": "cards.rarity",
"pk": 9,
"fields": {
"icons": "👑",
"normalized_id": 8,
"name": "Crown Rare",
"created_at": "2025-02-16T06:55:13.907Z",
"updated_at": "2025-02-16T06:55:13.907Z"
}
},
{
"model": "cards.rarity",
"pk": 10,
"fields": {
"icons": "🅿️",
"normalized_id": 9,
"name": "Promo",
"created_at": "2025-02-16T06:55:13.907Z",
"updated_at": "2025-02-16T06:55:13.907Z"
}
}
]

View file

@ -1,38 +0,0 @@
[
{
"model": "cards.cardset",
"pk": 1,
"fields": {
"name": "Promo-A",
"created_at": "2025-02-16T07:54:38.986Z",
"updated_at": "2025-02-16T07:54:38.986Z"
}
},
{
"model": "cards.cardset",
"pk": 2,
"fields": {
"name": "A1",
"created_at": "2025-02-16T07:54:04.325Z",
"updated_at": "2025-02-16T07:54:04.325Z"
}
},
{
"model": "cards.cardset",
"pk": 3,
"fields": {
"name": "A1a",
"created_at": "2025-02-16T07:54:08.471Z",
"updated_at": "2025-02-16T07:54:08.471Z"
}
},
{
"model": "cards.cardset",
"pk": 4,
"fields": {
"name": "A2",
"created_at": "2025-02-16T07:54:11.435Z",
"updated_at": "2025-02-16T07:54:11.435Z"
}
}
]

View file

@ -4,7 +4,7 @@
"pk": 1,
"fields": {
"name": "Promo-A",
"cardset": 1,
"cardset": "Promo-A",
"hex_color": "#1070EB",
"created_at": "2025-02-16T07:55:34.988Z",
"updated_at": "2025-02-16T07:55:34.988Z"
@ -15,7 +15,7 @@
"pk": 2,
"fields": {
"name": "Genetic Apex: Mewtwo",
"cardset": 2,
"cardset": "A1",
"hex_color": "#8040E0",
"created_at": "2025-02-16T07:54:57.445Z",
"updated_at": "2025-02-16T07:54:57.445Z"
@ -26,7 +26,7 @@
"pk": 3,
"fields": {
"name": "Genetic Apex: Charizard",
"cardset": 2,
"cardset": "A1",
"hex_color": "#E00202",
"created_at": "2025-02-16T07:54:52.381Z",
"updated_at": "2025-02-16T07:54:52.381Z"
@ -37,7 +37,7 @@
"pk": 4,
"fields": {
"name": "Genetic Apex: Pikachu",
"cardset": 2,
"cardset": "A1",
"hex_color": "#EB8600",
"created_at": "2025-02-16T07:55:05.097Z",
"updated_at": "2025-02-16T07:55:05.097Z"
@ -48,7 +48,7 @@
"pk": 5,
"fields": {
"name": "Mythical Island",
"cardset": 3,
"cardset": "A1a",
"hex_color": "#20AA80",
"created_at": "2025-02-16T07:55:11.916Z",
"updated_at": "2025-02-16T07:55:11.916Z"
@ -59,7 +59,7 @@
"pk": 6,
"fields": {
"name": "Space-Time Smackdown: Dialga",
"cardset": 4,
"cardset": "A2",
"hex_color": "#302FD9",
"created_at": "2025-02-16T07:55:17.582Z",
"updated_at": "2025-02-16T07:55:17.582Z"
@ -70,7 +70,7 @@
"pk": 7,
"fields": {
"name": "Space-Time Smackdown: Palkia",
"cardset": 4,
"cardset": "A2",
"hex_color": "#CF36E0",
"created_at": "2025-02-16T07:55:27.503Z",
"updated_at": "2025-02-16T07:55:27.503Z"

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,8 @@
"is_closed": false,
"hash": "8775ce1cz",
"initiated_by": 1,
"rarity_icon": "🔷🔷",
"rarity_level": 2,
"created_at": "2025-03-13T04:38:41.385Z",
"updated_at": "2025-03-13T04:38:41.385Z"
}
@ -17,6 +19,8 @@
"is_closed": false,
"hash": "daa6300dz",
"initiated_by": 1,
"rarity_icon": "🔷",
"rarity_level": 1,
"created_at": "2025-03-13T04:39:25.777Z",
"updated_at": "2025-03-13T04:39:25.777Z"
}
@ -28,6 +32,8 @@
"is_closed": false,
"hash": "e6cdbdf8z",
"initiated_by": 1,
"rarity_icon": "🔷🔷🔷",
"rarity_level": 3,
"created_at": "2025-03-13T04:40:07.727Z",
"updated_at": "2025-03-13T04:40:07.727Z"
}
@ -39,6 +45,8 @@
"is_closed": false,
"hash": "a975713ez",
"initiated_by": 1,
"rarity_icon": "🔷🔷🔷🔷",
"rarity_level": 4,
"created_at": "2025-03-13T04:40:29.957Z",
"updated_at": "2025-03-13T04:40:29.957Z"
}
@ -50,6 +58,8 @@
"is_closed": false,
"hash": "37dc0786z",
"initiated_by": 1,
"rarity_icon": "⭐️",
"rarity_level": 5,
"created_at": "2025-03-13T04:41:00.359Z",
"updated_at": "2025-03-13T04:41:00.359Z"
}
@ -61,6 +71,8 @@
"is_closed": false,
"hash": "f10208bdz",
"initiated_by": 1,
"rarity_icon": "🔷",
"rarity_level": 1,
"created_at": "2025-03-13T04:41:31.231Z",
"updated_at": "2025-03-13T04:41:31.231Z"
}
@ -72,6 +84,8 @@
"is_closed": false,
"hash": "88963192z",
"initiated_by": 1,
"rarity_icon": "🔷🔷",
"rarity_level": 2,
"created_at": "2025-03-13T04:43:07.737Z",
"updated_at": "2025-03-13T04:43:07.737Z"
}
@ -83,6 +97,8 @@
"is_closed": false,
"hash": "31a7aee8z",
"initiated_by": 1,
"rarity_icon": "🔷🔷🔷",
"rarity_level": 3,
"created_at": "2025-03-13T04:44:05.193Z",
"updated_at": "2025-03-13T04:44:05.193Z"
}
@ -94,6 +110,8 @@
"is_closed": false,
"hash": "7a79e1f7z",
"initiated_by": 1,
"rarity_icon": "🔷🔷🔷🔷",
"rarity_level": 4,
"created_at": "2025-03-13T04:44:35.634Z",
"updated_at": "2025-03-13T04:44:35.634Z"
}
@ -105,6 +123,8 @@
"is_closed": false,
"hash": "a465a255z",
"initiated_by": 1,
"rarity_icon": "⭐️",
"rarity_level": 5,
"created_at": "2025-03-13T04:45:02.040Z",
"updated_at": "2025-03-13T04:45:02.040Z"
}
@ -116,6 +136,8 @@
"is_closed": false,
"hash": "9d871edbz",
"initiated_by": 1,
"rarity_icon": "🔷🔷🔷🔷",
"rarity_level": 4,
"created_at": "2025-03-13T04:45:34.815Z",
"updated_at": "2025-03-13T04:45:34.815Z"
}
@ -127,6 +149,8 @@
"is_closed": false,
"hash": "32b34a89z",
"initiated_by": 2,
"rarity_icon": "🔷",
"rarity_level": 1,
"created_at": "2025-03-13T04:54:17.809Z",
"updated_at": "2025-03-13T04:54:17.809Z"
}
@ -138,6 +162,8 @@
"is_closed": false,
"hash": "f747edbdz",
"initiated_by": 2,
"rarity_icon": "🔷",
"rarity_level": 1,
"created_at": "2025-03-13T04:55:33.344Z",
"updated_at": "2025-03-13T04:55:33.344Z"
}
@ -149,6 +175,8 @@
"is_closed": false,
"hash": "9a13333dz",
"initiated_by": 2,
"rarity_icon": "🔷",
"rarity_level": 1,
"created_at": "2025-03-13T04:58:02.062Z",
"updated_at": "2025-03-13T04:58:02.062Z"
}
@ -160,6 +188,8 @@
"is_closed": false,
"hash": "5b0d6871z",
"initiated_by": 2,
"rarity_icon": "🔷",
"rarity_level": 1,
"created_at": "2025-03-13T04:59:11.177Z",
"updated_at": "2025-03-13T04:59:11.177Z"
}
@ -171,6 +201,8 @@
"is_closed": false,
"hash": "f012360cz",
"initiated_by": 2,
"rarity_icon": "🔷🔷",
"rarity_level": 2,
"created_at": "2025-03-13T05:00:49.530Z",
"updated_at": "2025-03-13T05:00:49.530Z"
}
@ -182,6 +214,8 @@
"is_closed": false,
"hash": "a6e927eaz",
"initiated_by": 2,
"rarity_icon": "🔷🔷",
"rarity_level": 2,
"created_at": "2025-03-13T05:00:53.037Z",
"updated_at": "2025-03-13T05:00:53.037Z"
}
@ -193,6 +227,8 @@
"is_closed": false,
"hash": "a5ec89b7z",
"initiated_by": 2,
"rarity_icon": "🔷🔷",
"rarity_level": 2,
"created_at": "2025-03-13T05:02:36.926Z",
"updated_at": "2025-03-13T05:02:36.926Z"
}
@ -204,6 +240,8 @@
"is_closed": false,
"hash": "ebf6a095z",
"initiated_by": 2,
"rarity_icon": "🔷🔷",
"rarity_level": 2,
"created_at": "2025-03-13T05:03:39.241Z",
"updated_at": "2025-03-13T05:03:39.241Z"
}
@ -215,6 +253,8 @@
"is_closed": false,
"hash": "c7541b41z",
"initiated_by": 2,
"rarity_icon": "🔷🔷",
"rarity_level": 2,
"created_at": "2025-03-13T05:05:22.304Z",
"updated_at": "2025-03-13T05:05:22.304Z"
}
@ -226,6 +266,8 @@
"is_closed": false,
"hash": "2b97019dz",
"initiated_by": 2,
"rarity_icon": "🔷🔷",
"rarity_level": 2,
"created_at": "2025-03-13T05:08:31.437Z",
"updated_at": "2025-03-13T05:08:31.437Z"
}
@ -237,6 +279,8 @@
"is_closed": false,
"hash": "5d90ca78z",
"initiated_by": 2,
"rarity_icon": "🔷🔷",
"rarity_level": 2,
"created_at": "2025-03-13T05:09:40.853Z",
"updated_at": "2025-03-13T05:09:40.853Z"
}

View file

@ -105,7 +105,11 @@
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-32 p-2 shadow">
<li>
<a class="justify-between" href="https://www.gravatar.com/profile/" target="_blank" rel="noopener noreferrer">
Profile
Profile
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
</li>
<li>

View file

@ -1,18 +1,10 @@
<div class="relative inline-block">
{% if num_decks == 1 %}
<div class="grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="background-color: {{ decks.0.hex_color }};">
{% elif num_decks == 2 %}
<div class="grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="background: linear-gradient(to right, {{ decks.0.hex_color }}, {{ decks.1.hex_color }});">
{% elif num_decks >= 3 %}
<div class="grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="background: linear-gradient(to right, {{ decks.0.hex_color }}, {{ decks.1.hex_color }}, {{ decks.2.hex_color }});">
{% else %}
<div class="grid grid-cols-4 grid-rows-2 px-2 py-2 h-16 w-36 text-white bg-gray-600 shadow-md shadow-black/50">
{% endif %}
<div class="row-span-1 col-span-4 truncate text-ellipsis self-start font-semibold leading-tight text-sm max-w-7/8">{{ card.name }}</div>
<div class="row-start-2 col-span-2 truncate self-end align-bottom text-xs">{{ card.rarity.icons }}</div>
<div class="row-start-2 col-start-3 col-span-2 text-right truncate self-end align-bottom font-semibold leading-none text-sm">{{ card.cardset.name }}</div>
<div class="grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="{{ style }}">
<div class="row-span-1 col-span-4 truncate text-ellipsis self-start font-semibold leading-tight text-sm max-w-7/8">{{ name }}</div>
<div class="row-start-2 col-span-2 truncate self-end align-bottom text-xs">{{ rarity }}</div>
<div class="row-start-2 col-start-3 col-span-2 text-right truncate self-end align-bottom font-semibold leading-none text-sm">{{ cardset }}</div>
</div>
<span class="card-quantity-badge absolute top-3.5 right-1 bg-gray-600 text-white text-xs font-semibold rounded-full px-2">
{% if is_template %}__QUANTITY__{% else %}{{ quantity }}{% endif %}
{{ quantity }}
</span>
</div>

View file

@ -1,6 +1,6 @@
{% load gravatar card_badge cache %}
{% cache 60 trade_offer offer.pk %}
{% cache 60 trade_offer offer_pk %}
<script>
if (!window.tradeOfferCard) {
window.tradeOfferCard = function() {
@ -34,7 +34,7 @@
<div class="flip-face front col-start-1 row-start-1 grid grid-cols-1 auto-rows-min gap-2 content-between">
<!-- Header -->
<div class="self-start">
<a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline block">
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline block">
<div class="py-4 mx-2 sm:mx-4">
<div class="grid grid-cols-3 items-center">
<div class="flex justify-center items-center">
@ -43,7 +43,7 @@
<div class="flex justify-center items-center">
<div class="avatar">
<div class="w-10 rounded-full">
{{ offer.initiated_by.user.email|gravatar:40 }}
{{ initiated_by_email|gravatar:40 }}
</div>
</div>
</div>
@ -56,7 +56,7 @@
</div>
<!-- Main Trade Offer Row -->
<div class="self-start">
<a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline block">
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline block">
<div class="px-2 pb-0">
<div class="grid grid-cols-2 gap-2 items-center">
<div class="flex flex-col items-center">
@ -106,7 +106,7 @@
</div>
<div class="self-end">
<div class="flex justify-between px-2 pb-2">
<div class="text-gray-500 text-sm tooltip tooltip-right" data-tip="ID: {{ offer.hash }}">
<div class="text-gray-500 text-sm tooltip tooltip-right" data-tip="ID: {{ offer_hash }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
@ -129,7 +129,7 @@
<!-- Placed in the same grid cell as the front face -->
<div class="flip-face back col-start-1 row-start-1 grid grid-cols-1 auto-rows-min gap-2 content-between" style="transform: rotateY(180deg);">
<div class="self-start">
<a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline">
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline">
<div class="py-4 mx-2 sm:mx-4">
<div class="grid grid-cols-3 items-center">
<div class="flex justify-center items-center">
@ -138,7 +138,7 @@
<div class="flex justify-center items-center">
<div class="avatar">
<div class="w-10 rounded-full">
{{ offer.initiated_by.user.email|gravatar:40 }}
{{ initiated_by_email|gravatar:40 }}
</div>
</div>
</div>
@ -152,9 +152,9 @@
<div class="self-start">
<div class="px-2 pb-0">
<div class="overflow-hidden">
{% if offer.acceptances.first %}
{% if acceptances.0 %}
<div class="space-y-3">
{% with acceptance=offer.acceptances.first %}
{% with acceptance=acceptances.0 %}
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline"
data-tooltip-html='<div class="flex items-center space-x-2">
<div class="avatar">
@ -182,7 +182,7 @@
{% endif %}
</div>
<div x-show="acceptanceExpanded" x-collapse.duration.500ms class="space-y-3">
{% for acceptance in offer.acceptances.all|slice:"1:" %}
{% for acceptance in acceptances|slice:"1:" %}
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline"
data-tooltip-html='<div class="flex items-center space-x-2">
<div class="avatar">
@ -209,7 +209,7 @@
</div>
</div>
<div class="flex justify-center my-1 h-5">
{% if offer.acceptances.all|length > 1 %}
{% if acceptances|length > 1 %}
<svg @click="acceptanceExpanded = !acceptanceExpanded"
x-bind:class="{ 'rotate-180': acceptanceExpanded }"
class="transition-transform duration-500 h-5 w-5 cursor-pointer"
@ -230,10 +230,10 @@
</div>
<div class="px-1 text-center">
<span class="text-sm font-semibold">
Acceptances ({{ offer.acceptances.all|length }})
Acceptances ({{ acceptances|length }})
</span>
</div>
<div class="text-gray-500 text-sm tooltip tooltip-left" data-tip="ID: {{ offer.hash }}">
<div class="text-gray-500 text-sm tooltip tooltip-left" data-tip="ID: {{ offer_hash }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-16 18:18
# Generated by Django 5.1.2 on 2025-03-17 20:39
import django.db.models.deletion
from django.db import migrations, models
@ -20,10 +20,11 @@ class Migration(migrations.Migration):
('id', models.AutoField(primary_key=True, serialize=False)),
('is_closed', models.BooleanField(db_index=True, default=False)),
('hash', models.CharField(editable=False, max_length=9)),
('rarity_icon', models.CharField(max_length=8, null=True)),
('rarity_level', models.IntegerField(null=True)),
('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')),
('rarity', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to='cards.rarity')),
],
),
migrations.CreateModel(

View file

@ -7,30 +7,18 @@ from accounts.models import FriendCode
class TradeOfferManager(models.Manager):
def get_queryset(self):
queryset = super().get_queryset().select_related("initiated_by", "initiated_by__user", "rarity")
queryset = queryset.prefetch_related(
Prefetch(
"trade_offer_want_cards",
queryset=TradeOfferWantCard.objects.select_related("card").prefetch_related('card__decks').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").prefetch_related('card__decks').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")
),
queryset = super().get_queryset().select_related(
"initiated_by__user",
).prefetch_related(
"trade_offer_have_cards__card",
"trade_offer_want_cards__card",
"acceptances",
"acceptances__requested_card",
"acceptances__offered_card",
"acceptances__accepted_by__user",
).order_by("-updated_at")
return queryset
class TradeOffer(models.Model):
objects = TradeOfferManager()
@ -42,14 +30,8 @@ class TradeOffer(models.Model):
on_delete=models.PROTECT,
related_name='initiated_trade_offers'
)
rarity = models.ForeignKey(
"cards.Rarity",
on_delete=models.PROTECT,
null=True,
blank=True,
editable=False,
db_index=True
)
rarity_icon = models.CharField(max_length=8, null=True)
rarity_level = models.IntegerField(null=True)
want_cards = models.ManyToManyField(
"cards.Card",
related_name='trade_offers_want',

View file

@ -5,36 +5,40 @@ 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.rarity
if it hasn't been set already.
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.
"""
# Combine cards from both sides.
combined_cards = list(instance.have_cards.all()) + list(instance.want_cards.all())
if not combined_cards:
return
# Gather the Rarity instances from the cards.
rarities = {card.normalized_rarity for card in combined_cards}
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.")
# If trade offer's rarity isn't set yet, update it.
if instance.rarity is None:
instance.rarity = combined_cards[0].normalized_rarity
instance.save(update_fields=["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":
validate_and_set_trade_offer_rarity(instance)
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":
validate_and_set_trade_offer_rarity(instance)
transaction.on_commit(lambda: validate_and_set_trade_offer_rarity(instance))
ACTIVE_STATES = [
TradeAcceptance.AcceptanceState.ACCEPTED,

View file

@ -20,8 +20,17 @@ def render_trade_offer(context, offer):
if card.quantity > card.qty_accepted
]
acceptances = [acceptance for acceptance in list(offer.acceptances.all())
if acceptance.is_active
]
return {
'offer': offer,
'offer_pk': offer.pk,
'offer_hash': offer.hash,
'rarity_icon': offer.rarity_icon,
'initiated_by_email': offer.initiated_by.user.email,
'initiated_by_username': offer.initiated_by.user.username,
'acceptances': acceptances,
'have_cards_available': have_cards_available,
'want_cards_available': want_cards_available,
}

View file

@ -16,7 +16,7 @@ from .models import TradeOffer, TradeAcceptance
from .forms import (TradeOfferAcceptForm,
TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm)
from cards.models import Card
from silk.profiling.profiler import silk_profile
#from silk.profiling.profiler import silk_profile
class TradeOfferCreateView(LoginRequiredMixin, CreateView):
model = TradeOffer
@ -42,7 +42,7 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
context = super().get_context_data(**kwargs)
from cards.models import Card
# Ensure available_cards is a proper QuerySet
context["cards"] = Card.objects.all().order_by("name", "rarity__pk") \
context["cards"] = Card.objects.all().order_by("name", "rarity_level") \
.select_related("rarity", "cardset") \
.prefetch_related("decks")
friend_codes = self.request.user.friend_codes.all()
@ -68,7 +68,7 @@ class TradeOfferAllListView(ListView):
model = TradeOffer
template_name = "trades/trade_offer_all_list.html"
@silk_profile(name="Trade Offer All List- Get Context Data")
#@silk_profile(name="Trade Offer All List- Get Context Data")
def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(**kwargs)
request = self.request
@ -88,14 +88,14 @@ class TradeOfferAllListView(ListView):
context["all_trade_offers_paginated"] = offers_paginator.get_page(offers_page)
return context
@silk_profile(name="Trade Offer All List- Render to Response")
#@silk_profile(name="Trade Offer All List- Render to Response")
def render_to_response(self, context, **response_kwargs):
# For AJAX requests, return only the paginated fragment.
if self.request.headers.get("X-Requested-With") == "XMLHttpRequest":
page = self.request.GET.get("page")
show_closed = self.request.GET.get("show_closed", "false").lower() == "true"
queryset = TradeOffer.objects.all()
queryset = TradeOffer.objects
if show_closed:
queryset = queryset.filter(is_closed=True)
else:
@ -185,7 +185,7 @@ class TradeOfferMyListView(LoginRequiredMixin, ListView):
other_acceptances = involved_acceptances.exclude(pk__in=waiting_acceptances.values("pk"))
return Paginator(other_acceptances, 10).get_page(page_param)
@silk_profile(name="Trade Offer My List- Get Context Data")
#@silk_profile(name="Trade Offer My List- Get Context Data")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
request = self.request
@ -206,7 +206,7 @@ class TradeOfferMyListView(LoginRequiredMixin, ListView):
return context
@silk_profile(name="Trade Offer My List- Render to Response")
#@silk_profile(name="Trade Offer My List- Render to Response")
def render_to_response(self, context, **response_kwargs):
# For AJAX requests, return only the paginated fragment.
if self.request.headers.get("X-Requested-With") == "XMLHttpRequest":
@ -318,7 +318,7 @@ class TradeOfferSearchView(ListView):
results.append((card_id, qty))
return results
@silk_profile(name="Trade Offer Search- Get Queryset")
#@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.
@ -355,18 +355,17 @@ class TradeOfferSearchView(ListView):
return qs.distinct()
@silk_profile(name="Trade Offer Search- Post")
#@silk_profile(name="Trade Offer Search- Post")
def post(self, request, *args, **kwargs):
# For POST, simply process the search through get().
return self.get(request, *args, **kwargs)
@silk_profile(name="Trade Offer Search- Get Context Data")
#@silk_profile(name="Trade Offer Search- Get Context Data")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
from cards.models import Card
# Populate available_cards to re-populate the multiselects.
context["cards"] = Card.objects.all().order_by("name", "rarity__pk") \
.select_related("rarity", "cardset")
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")
@ -375,7 +374,7 @@ class TradeOfferSearchView(ListView):
context["wanted_cards"] = []
return context
@silk_profile(name="Trade Offer Search- Render to Response")
#@silk_profile(name="Trade Offer Search- Render to Response")
def render_to_response(self, context, **response_kwargs):
"""
Render the AJAX fragment if the request is AJAX; otherwise, render the complete page.
@ -395,7 +394,7 @@ class TradeOfferDetailView(LoginRequiredMixin, DetailView):
model = TradeOffer
template_name = "trades/trade_offer_detail.html"
@silk_profile(name="Trade Offer Detail- Get Context Data")
#@silk_profile(name="Trade Offer Detail- Get Context Data")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
trade_offer = self.get_object()