Add tradeOffer image field, tweak image generation to only fire once per tradeOffer, even with simultaneous requests

This commit is contained in:
badblocks 2025-03-29 15:13:57 -07:00
parent 138a929da6
commit 2d826734a0
9 changed files with 127 additions and 80 deletions

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-29 03:33 # Generated by Django 5.1.2 on 2025-03-29 21:23
import accounts.models import accounts.models
import django.contrib.auth.models import django.contrib.auth.models

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-29 03:33 # Generated by Django 5.1.2 on 2025-03-29 21:23
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View file

@ -169,6 +169,12 @@ STATIC_URL = "/static/"
# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
STATICFILES_DIRS = [BASE_DIR / "static"] STATICFILES_DIRS = [BASE_DIR / "static"]
# https://docs.djangoproject.com/en/dev/ref/settings/#media-root
MEDIA_ROOT = BASE_DIR / "media"
# https://docs.djangoproject.com/en/dev/ref/settings/#media-url
MEDIA_URL = "/media/"
# https://whitenoise.readthedocs.io/en/latest/django.html # https://whitenoise.readthedocs.io/en/latest/django.html
STORAGES = { STORAGES = {
"default": { "default": {

View file

@ -23,6 +23,7 @@ idna==3.4
imgkit==1.2.3 imgkit==1.2.3
oauthlib==3.2.2 oauthlib==3.2.2
packaging==23.1 packaging==23.1
pillow==11.1.0
psycopg==3.2.3 psycopg==3.2.3
psycopg-binary==3.2.3 psycopg-binary==3.2.3
pycparser==2.21 pycparser==2.21

View file

@ -8,6 +8,7 @@
"initiated_by": 1, "initiated_by": 1,
"rarity_icon": "🔷🔷", "rarity_icon": "🔷🔷",
"rarity_level": 2, "rarity_level": 2,
"image": null,
"created_at": "2025-03-13T04:38:41.385Z", "created_at": "2025-03-13T04:38:41.385Z",
"updated_at": "2025-03-13T04:38:41.385Z" "updated_at": "2025-03-13T04:38:41.385Z"
} }
@ -21,6 +22,7 @@
"initiated_by": 1, "initiated_by": 1,
"rarity_icon": "🔷", "rarity_icon": "🔷",
"rarity_level": 1, "rarity_level": 1,
"image": null,
"created_at": "2025-03-13T04:39:25.777Z", "created_at": "2025-03-13T04:39:25.777Z",
"updated_at": "2025-03-13T04:39:25.777Z" "updated_at": "2025-03-13T04:39:25.777Z"
} }
@ -34,6 +36,7 @@
"initiated_by": 1, "initiated_by": 1,
"rarity_icon": "🔷🔷🔷", "rarity_icon": "🔷🔷🔷",
"rarity_level": 3, "rarity_level": 3,
"image": null,
"created_at": "2025-03-13T04:40:07.727Z", "created_at": "2025-03-13T04:40:07.727Z",
"updated_at": "2025-03-13T04:40:07.727Z" "updated_at": "2025-03-13T04:40:07.727Z"
} }
@ -47,6 +50,7 @@
"initiated_by": 1, "initiated_by": 1,
"rarity_icon": "🔷🔷🔷🔷", "rarity_icon": "🔷🔷🔷🔷",
"rarity_level": 4, "rarity_level": 4,
"image": null,
"created_at": "2025-03-13T04:40:29.957Z", "created_at": "2025-03-13T04:40:29.957Z",
"updated_at": "2025-03-13T04:40:29.957Z" "updated_at": "2025-03-13T04:40:29.957Z"
} }
@ -60,6 +64,7 @@
"initiated_by": 1, "initiated_by": 1,
"rarity_icon": "⭐️", "rarity_icon": "⭐️",
"rarity_level": 5, "rarity_level": 5,
"image": null,
"created_at": "2025-03-13T04:41:00.359Z", "created_at": "2025-03-13T04:41:00.359Z",
"updated_at": "2025-03-13T04:41:00.359Z" "updated_at": "2025-03-13T04:41:00.359Z"
} }
@ -73,6 +78,7 @@
"initiated_by": 1, "initiated_by": 1,
"rarity_icon": "🔷", "rarity_icon": "🔷",
"rarity_level": 1, "rarity_level": 1,
"image": null,
"created_at": "2025-03-13T04:41:31.231Z", "created_at": "2025-03-13T04:41:31.231Z",
"updated_at": "2025-03-13T04:41:31.231Z" "updated_at": "2025-03-13T04:41:31.231Z"
} }
@ -86,6 +92,7 @@
"initiated_by": 1, "initiated_by": 1,
"rarity_icon": "🔷🔷", "rarity_icon": "🔷🔷",
"rarity_level": 2, "rarity_level": 2,
"image": null,
"created_at": "2025-03-13T04:43:07.737Z", "created_at": "2025-03-13T04:43:07.737Z",
"updated_at": "2025-03-13T04:43:07.737Z" "updated_at": "2025-03-13T04:43:07.737Z"
} }
@ -99,6 +106,7 @@
"initiated_by": 1, "initiated_by": 1,
"rarity_icon": "🔷🔷🔷", "rarity_icon": "🔷🔷🔷",
"rarity_level": 3, "rarity_level": 3,
"image": null,
"created_at": "2025-03-13T04:44:05.193Z", "created_at": "2025-03-13T04:44:05.193Z",
"updated_at": "2025-03-13T04:44:05.193Z" "updated_at": "2025-03-13T04:44:05.193Z"
} }
@ -112,6 +120,7 @@
"initiated_by": 1, "initiated_by": 1,
"rarity_icon": "🔷🔷🔷🔷", "rarity_icon": "🔷🔷🔷🔷",
"rarity_level": 4, "rarity_level": 4,
"image": null,
"created_at": "2025-03-13T04:44:35.634Z", "created_at": "2025-03-13T04:44:35.634Z",
"updated_at": "2025-03-13T04:44:35.634Z" "updated_at": "2025-03-13T04:44:35.634Z"
} }
@ -125,6 +134,7 @@
"initiated_by": 1, "initiated_by": 1,
"rarity_icon": "⭐️", "rarity_icon": "⭐️",
"rarity_level": 5, "rarity_level": 5,
"image": null,
"created_at": "2025-03-13T04:45:02.040Z", "created_at": "2025-03-13T04:45:02.040Z",
"updated_at": "2025-03-13T04:45:02.040Z" "updated_at": "2025-03-13T04:45:02.040Z"
} }
@ -138,6 +148,7 @@
"initiated_by": 1, "initiated_by": 1,
"rarity_icon": "🔷🔷🔷🔷", "rarity_icon": "🔷🔷🔷🔷",
"rarity_level": 4, "rarity_level": 4,
"image": null,
"created_at": "2025-03-13T04:45:34.815Z", "created_at": "2025-03-13T04:45:34.815Z",
"updated_at": "2025-03-13T04:45:34.815Z" "updated_at": "2025-03-13T04:45:34.815Z"
} }
@ -151,6 +162,7 @@
"initiated_by": 2, "initiated_by": 2,
"rarity_icon": "🔷", "rarity_icon": "🔷",
"rarity_level": 1, "rarity_level": 1,
"image": null,
"created_at": "2025-03-13T04:54:17.809Z", "created_at": "2025-03-13T04:54:17.809Z",
"updated_at": "2025-03-13T04:54:17.809Z" "updated_at": "2025-03-13T04:54:17.809Z"
} }
@ -164,6 +176,7 @@
"initiated_by": 2, "initiated_by": 2,
"rarity_icon": "🔷", "rarity_icon": "🔷",
"rarity_level": 1, "rarity_level": 1,
"image": null,
"created_at": "2025-03-13T04:55:33.344Z", "created_at": "2025-03-13T04:55:33.344Z",
"updated_at": "2025-03-13T04:55:33.344Z" "updated_at": "2025-03-13T04:55:33.344Z"
} }
@ -177,6 +190,7 @@
"initiated_by": 2, "initiated_by": 2,
"rarity_icon": "🔷", "rarity_icon": "🔷",
"rarity_level": 1, "rarity_level": 1,
"image": null,
"created_at": "2025-03-13T04:58:02.062Z", "created_at": "2025-03-13T04:58:02.062Z",
"updated_at": "2025-03-13T04:58:02.062Z" "updated_at": "2025-03-13T04:58:02.062Z"
} }
@ -190,6 +204,7 @@
"initiated_by": 2, "initiated_by": 2,
"rarity_icon": "🔷", "rarity_icon": "🔷",
"rarity_level": 1, "rarity_level": 1,
"image": null,
"created_at": "2025-03-13T04:59:11.177Z", "created_at": "2025-03-13T04:59:11.177Z",
"updated_at": "2025-03-13T04:59:11.177Z" "updated_at": "2025-03-13T04:59:11.177Z"
} }
@ -203,6 +218,7 @@
"initiated_by": 2, "initiated_by": 2,
"rarity_icon": "🔷🔷", "rarity_icon": "🔷🔷",
"rarity_level": 2, "rarity_level": 2,
"image": null,
"created_at": "2025-03-13T05:00:49.530Z", "created_at": "2025-03-13T05:00:49.530Z",
"updated_at": "2025-03-13T05:00:49.530Z" "updated_at": "2025-03-13T05:00:49.530Z"
} }
@ -216,6 +232,7 @@
"initiated_by": 2, "initiated_by": 2,
"rarity_icon": "🔷🔷", "rarity_icon": "🔷🔷",
"rarity_level": 2, "rarity_level": 2,
"image": null,
"created_at": "2025-03-13T05:00:53.037Z", "created_at": "2025-03-13T05:00:53.037Z",
"updated_at": "2025-03-13T05:00:53.037Z" "updated_at": "2025-03-13T05:00:53.037Z"
} }
@ -229,6 +246,7 @@
"initiated_by": 2, "initiated_by": 2,
"rarity_icon": "🔷🔷", "rarity_icon": "🔷🔷",
"rarity_level": 2, "rarity_level": 2,
"image": null,
"created_at": "2025-03-13T05:02:36.926Z", "created_at": "2025-03-13T05:02:36.926Z",
"updated_at": "2025-03-13T05:02:36.926Z" "updated_at": "2025-03-13T05:02:36.926Z"
} }
@ -242,6 +260,7 @@
"initiated_by": 2, "initiated_by": 2,
"rarity_icon": "🔷🔷", "rarity_icon": "🔷🔷",
"rarity_level": 2, "rarity_level": 2,
"image": null,
"created_at": "2025-03-13T05:03:39.241Z", "created_at": "2025-03-13T05:03:39.241Z",
"updated_at": "2025-03-13T05:03:39.241Z" "updated_at": "2025-03-13T05:03:39.241Z"
} }
@ -255,6 +274,7 @@
"initiated_by": 2, "initiated_by": 2,
"rarity_icon": "🔷🔷", "rarity_icon": "🔷🔷",
"rarity_level": 2, "rarity_level": 2,
"image": null,
"created_at": "2025-03-13T05:05:22.304Z", "created_at": "2025-03-13T05:05:22.304Z",
"updated_at": "2025-03-13T05:05:22.304Z" "updated_at": "2025-03-13T05:05:22.304Z"
} }
@ -268,6 +288,7 @@
"initiated_by": 2, "initiated_by": 2,
"rarity_icon": "🔷🔷", "rarity_icon": "🔷🔷",
"rarity_level": 2, "rarity_level": 2,
"image": null,
"created_at": "2025-03-13T05:08:31.437Z", "created_at": "2025-03-13T05:08:31.437Z",
"updated_at": "2025-03-13T05:08:31.437Z" "updated_at": "2025-03-13T05:08:31.437Z"
} }
@ -281,6 +302,7 @@
"initiated_by": 2, "initiated_by": 2,
"rarity_icon": "🔷🔷", "rarity_icon": "🔷🔷",
"rarity_level": 2, "rarity_level": 2,
"image": null,
"created_at": "2025-03-13T05:09:40.853Z", "created_at": "2025-03-13T05:09:40.853Z",
"updated_at": "2025-03-13T05:09:40.853Z" "updated_at": "2025-03-13T05:09:40.853Z"
} }

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-29 03:33 # Generated by Django 5.1.2 on 2025-03-29 21:23
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@ -22,6 +22,7 @@ class Migration(migrations.Migration):
('hash', models.CharField(editable=False, max_length=9)), ('hash', models.CharField(editable=False, max_length=9)),
('rarity_icon', models.CharField(max_length=8, null=True)), ('rarity_icon', models.CharField(max_length=8, null=True)),
('rarity_level', models.IntegerField(null=True)), ('rarity_level', models.IntegerField(null=True)),
('image', models.ImageField(blank=True, null=True, upload_to='trade_offers/')),
('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)),
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_trade_offers', to='accounts.friendcode')), ('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_trade_offers', to='accounts.friendcode')),

View file

@ -6,6 +6,21 @@ from cards.models import Card
from accounts.models import FriendCode from accounts.models import FriendCode
from datetime import timedelta from datetime import timedelta
from django.utils import timezone from django.utils import timezone
import uuid
def generate_tradeoffer_hash():
"""
Generates a unique 9-character hash for a TradeOffer.
The last character 'z' indicates its type.
"""
return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "z"
def generate_tradeacceptance_hash():
"""
Generates a unique 9-character hash for a TradeAcceptance.
The last character 'y' indicates its type.
"""
return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "y"
class TradeOfferManager(models.Manager): class TradeOfferManager(models.Manager):
@ -32,7 +47,7 @@ class TradeOfferAllManager(models.Manager):
class TradeOffer(models.Model): class TradeOffer(models.Model):
objects = TradeOfferManager() objects = TradeOfferManager()
all_offers = TradeOfferAllManager() # New unfiltered manager all_offers = TradeOfferAllManager()
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)
@ -44,6 +59,7 @@ class TradeOffer(models.Model):
) )
rarity_icon = models.CharField(max_length=8, null=True) rarity_icon = models.CharField(max_length=8, null=True)
rarity_level = models.IntegerField(null=True) rarity_level = models.IntegerField(null=True)
image = models.ImageField(upload_to='trade_offers/', null=True, blank=True)
want_cards = models.ManyToManyField( want_cards = models.ManyToManyField(
"cards.Card", "cards.Card",
related_name='trade_offers_want', related_name='trade_offers_want',
@ -63,11 +79,9 @@ class TradeOffer(models.Model):
return f"Want: {want_names} -> Have: {have_names}" return f"Want: {want_names} -> Have: {have_names}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_new = self.pk is None if not self.hash:
self.hash = generate_tradeoffer_hash()
super().save(*args, **kwargs) super().save(*args, **kwargs)
if is_new and not self.hash:
self.hash = hashlib.md5((str(self.id) + "z").encode("utf-8")).hexdigest()[:8] + "z"
super().save(update_fields=["hash"])
def update_rarity_fields(self): def update_rarity_fields(self):
""" """
@ -324,11 +338,9 @@ class TradeAcceptance(models.Model):
self.save(update_fields=["state"]) self.save(update_fields=["state"])
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_new = self.pk is None if not self.hash:
self.hash = generate_tradeacceptance_hash()
super().save(*args, **kwargs) super().save(*args, **kwargs)
if is_new and not self.hash:
self.hash = hashlib.md5((str(self.id) + "y").encode("utf-8")).hexdigest()[:8] + "y"
super().save(update_fields=["hash"])
def __str__(self): def __str__(self):
return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, " return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, "

View file

@ -20,7 +20,7 @@ urlpatterns = [
path("my/", TradeOfferMyListView.as_view(), name="trade_offer_my_list"), path("my/", TradeOfferMyListView.as_view(), name="trade_offer_my_list"),
path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"), path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"),
path("<int:pk>/", TradeOfferDetailView.as_view(), name="trade_offer_detail"), path("<int:pk>/", TradeOfferDetailView.as_view(), name="trade_offer_detail"),
path("<int:pk>.png", cache_page(15)(TradeOfferPNGView.as_view()), name="trade_offer_png"), path("<int:pk>.png", TradeOfferPNGView.as_view(), name="trade_offer_png"),
path("delete/<int:pk>/", TradeOfferDeleteView.as_view(), name="trade_offer_delete"), path("delete/<int:pk>/", TradeOfferDeleteView.as_view(), name="trade_offer_delete"),
path("offer/<int:offer_pk>", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"), path("offer/<int:offer_pk>", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"),
path("accept/<int:pk>/", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"), path("accept/<int:pk>/", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"),

View file

@ -599,81 +599,86 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, Upd
class TradeOfferPNGView(View): class TradeOfferPNGView(View):
""" """
Generate a PNG screenshot of the rendered trade offer detail page using Playwright. Generate a PNG screenshot of the rendered trade offer detail page using Playwright.
This view loads the SVG representation generated by the trade_offer_svg template tag, This view uses PostgreSQL advisory locks to ensure that only one generation process
wraps it in a minimal HTML document, and converts it to PNG using Playwright. runs at a time for a given TradeOffer. The generated PNG is then cached in the
TradeOffer model's `image` field (assumed to be an ImageField).
""" """
def get_lock_key(self, trade_offer_id):
# Use the trade_offer_id as the lock key; adjust if needed.
return trade_offer_id
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.http import HttpResponse
from django.core.files.base import ContentFile
trade_offer = get_object_or_404(TradeOffer, pk=kwargs['pk']) trade_offer = get_object_or_404(TradeOffer, pk=kwargs['pk'])
from trades.templatetags import trade_offer_tags # If the image is already generated and stored, serve it directly.
# Generate context for the SVG template tag. if trade_offer.image:
tag_context = trade_offer_tags.render_trade_offer_png( trade_offer.image.open()
{'request': request}, trade_offer, show_friend_code=True print(f"Serving cached image for trade offer {trade_offer.pk}")
) return HttpResponse(trade_offer.image.read(), content_type="image/png")
# Use provided dimensions from the context # Acquire PostgreSQL advisory lock to prevent concurrent generation.
image_width = tag_context.get('image_width') from django.db import connection
image_height = tag_context.get('image_height') lock_key = self.get_lock_key(trade_offer.pk)
with connection.cursor() as cursor:
html = render_to_string("templatetags/trade_offer_png.html", tag_context) cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key])
try:
# If there's a query parameter 'debug' set to true, render the HTML to the response. # Double-check if the image was generated while waiting for the lock.
if request.GET.get('debug'): trade_offer.refresh_from_db()
return HttpResponse(html, content_type="text/html") if trade_offer.image:
trade_offer.image.open()
css = render_to_string("static/css/dist/styles.css") print(f"Serving recently-cached image for trade offer {trade_offer.pk}")
return HttpResponse(trade_offer.image.read(), content_type="image/png")
# Launch Playwright to render the HTML and capture a screenshot.
with sync_playwright() as p: print(f"Generating PNG for trade offer {trade_offer.pk}")
print("Launching browser") # Generate PNG using Playwright as before.
browser = p.chromium.launch( from trades.templatetags import trade_offer_tags
headless=True, tag_context = trade_offer_tags.render_trade_offer_png(
args=[ {'request': request}, trade_offer, show_friend_code=True
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--disable-gpu",
#"--single-process",
"--no-zygote",
"--disable-audio-output",
#"--disable-software-rasterizer",
"--disable-webgl",
#"--disable-web-security",
#"--disable-features=LazyFrameLoading",
#"--disable-features=IsolateOrigins",
#"--disable-background-networking",
"--no-first-run",
]
) )
print("Launched browser, creating context") image_width = tag_context.get('image_width')
context_browser = browser.new_context(viewport={"width": image_width, "height": image_height}) image_height = tag_context.get('image_height')
print("Created context, creating page") if not image_width or not image_height:
page = context_browser.new_page() raise ValueError("Could not determine image dimensions from tag_context")
print("Created page, setting content") html = render_to_string("templatetags/trade_offer_png.html", tag_context)
# Listen for all console logs, errors, and warnings
page.on("console", lambda msg: print(f"Console {msg.type}: {msg.text}"))
page.on("pageerror", lambda err: print(f"Page error: {err}"))
# Listen specifically for failed resource loads
page.on("requestfailed", lambda request: print(f"Failed to load: {request.url} - {request.failure.error_text}"))
# # Instead of using a link tag, let's inject the CSS directly
# css = render_to_string("static/css/dist/styles.css")
# page.add_style_tag(content=css)
page.set_content(html, wait_until="domcontentloaded")
print("Set content, waiting for element")
element = page.wait_for_selector(".trade-offer-card-screenshot")
print("Found element, capturing screenshot")
screenshot_bytes = element.screenshot(type="png", omit_background=True)
print("Captured screenshot, closing browser")
browser.close()
print("Closed browser, returning screenshot")
return HttpResponse(screenshot_bytes, content_type="image/png") from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=[
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--disable-gpu",
"--no-zygote",
"--disable-audio-output",
"--disable-webgl",
"--no-first-run",
]
)
context_browser = browser.new_context(viewport={"width": image_width, "height": image_height})
page = context_browser.new_page()
page.on("console", lambda msg: print(f"Console {msg.type}: {msg.text}"))
page.on("pageerror", lambda err: print(f"Page error: {err}"))
page.on("requestfailed", lambda req: print(f"Failed to load: {req.url} - {req.failure.error_text}"))
page.set_content(html, wait_until="domcontentloaded")
element = page.wait_for_selector(".trade-offer-card-screenshot")
screenshot_bytes = element.screenshot(type="png", omit_background=True)
browser.close()
# Save the generated PNG to the TradeOffer model (requires an ImageField named `image`).
filename = f"trade_offer_{trade_offer.pk}.png"
trade_offer.image.save(filename, ContentFile(screenshot_bytes))
trade_offer.save(update_fields=["image"])
return HttpResponse(screenshot_bytes, content_type="image/png")
finally:
# Release the advisory lock.
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
class TradeOfferCreateConfirmView(LoginRequiredMixin, View): class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
""" """