diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index 76a3141..2636f98 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -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 django.contrib.auth.models diff --git a/cards/migrations/0001_initial.py b/cards/migrations/0001_initial.py index 90b83d8..8589f72 100644 --- a/cards/migrations/0001_initial.py +++ b/cards/migrations/0001_initial.py @@ -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 from django.db import migrations, models diff --git a/django_project/settings.py b/django_project/settings.py index 0fbda05..dab1abf 100644 --- a/django_project/settings.py +++ b/django_project/settings.py @@ -169,6 +169,12 @@ STATIC_URL = "/static/" # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS 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 STORAGES = { "default": { diff --git a/requirements.txt b/requirements.txt index 55d4b3b..d83527e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,7 @@ idna==3.4 imgkit==1.2.3 oauthlib==3.2.2 packaging==23.1 +pillow==11.1.0 psycopg==3.2.3 psycopg-binary==3.2.3 pycparser==2.21 diff --git a/seed/0005_TradeOffers.json b/seed/0005_TradeOffers.json index 0a3f30c..a638d16 100644 --- a/seed/0005_TradeOffers.json +++ b/seed/0005_TradeOffers.json @@ -8,6 +8,7 @@ "initiated_by": 1, "rarity_icon": "🔷🔷", "rarity_level": 2, + "image": null, "created_at": "2025-03-13T04:38:41.385Z", "updated_at": "2025-03-13T04:38:41.385Z" } @@ -21,6 +22,7 @@ "initiated_by": 1, "rarity_icon": "🔷", "rarity_level": 1, + "image": null, "created_at": "2025-03-13T04:39:25.777Z", "updated_at": "2025-03-13T04:39:25.777Z" } @@ -34,6 +36,7 @@ "initiated_by": 1, "rarity_icon": "🔷🔷🔷", "rarity_level": 3, + "image": null, "created_at": "2025-03-13T04:40:07.727Z", "updated_at": "2025-03-13T04:40:07.727Z" } @@ -47,6 +50,7 @@ "initiated_by": 1, "rarity_icon": "🔷🔷🔷🔷", "rarity_level": 4, + "image": null, "created_at": "2025-03-13T04:40:29.957Z", "updated_at": "2025-03-13T04:40:29.957Z" } @@ -60,6 +64,7 @@ "initiated_by": 1, "rarity_icon": "⭐️", "rarity_level": 5, + "image": null, "created_at": "2025-03-13T04:41:00.359Z", "updated_at": "2025-03-13T04:41:00.359Z" } @@ -73,6 +78,7 @@ "initiated_by": 1, "rarity_icon": "🔷", "rarity_level": 1, + "image": null, "created_at": "2025-03-13T04:41:31.231Z", "updated_at": "2025-03-13T04:41:31.231Z" } @@ -86,6 +92,7 @@ "initiated_by": 1, "rarity_icon": "🔷🔷", "rarity_level": 2, + "image": null, "created_at": "2025-03-13T04:43:07.737Z", "updated_at": "2025-03-13T04:43:07.737Z" } @@ -99,6 +106,7 @@ "initiated_by": 1, "rarity_icon": "🔷🔷🔷", "rarity_level": 3, + "image": null, "created_at": "2025-03-13T04:44:05.193Z", "updated_at": "2025-03-13T04:44:05.193Z" } @@ -112,6 +120,7 @@ "initiated_by": 1, "rarity_icon": "🔷🔷🔷🔷", "rarity_level": 4, + "image": null, "created_at": "2025-03-13T04:44:35.634Z", "updated_at": "2025-03-13T04:44:35.634Z" } @@ -125,6 +134,7 @@ "initiated_by": 1, "rarity_icon": "⭐️", "rarity_level": 5, + "image": null, "created_at": "2025-03-13T04:45:02.040Z", "updated_at": "2025-03-13T04:45:02.040Z" } @@ -138,6 +148,7 @@ "initiated_by": 1, "rarity_icon": "🔷🔷🔷🔷", "rarity_level": 4, + "image": null, "created_at": "2025-03-13T04:45:34.815Z", "updated_at": "2025-03-13T04:45:34.815Z" } @@ -151,6 +162,7 @@ "initiated_by": 2, "rarity_icon": "🔷", "rarity_level": 1, + "image": null, "created_at": "2025-03-13T04:54:17.809Z", "updated_at": "2025-03-13T04:54:17.809Z" } @@ -164,6 +176,7 @@ "initiated_by": 2, "rarity_icon": "🔷", "rarity_level": 1, + "image": null, "created_at": "2025-03-13T04:55:33.344Z", "updated_at": "2025-03-13T04:55:33.344Z" } @@ -177,6 +190,7 @@ "initiated_by": 2, "rarity_icon": "🔷", "rarity_level": 1, + "image": null, "created_at": "2025-03-13T04:58:02.062Z", "updated_at": "2025-03-13T04:58:02.062Z" } @@ -190,6 +204,7 @@ "initiated_by": 2, "rarity_icon": "🔷", "rarity_level": 1, + "image": null, "created_at": "2025-03-13T04:59:11.177Z", "updated_at": "2025-03-13T04:59:11.177Z" } @@ -203,6 +218,7 @@ "initiated_by": 2, "rarity_icon": "🔷🔷", "rarity_level": 2, + "image": null, "created_at": "2025-03-13T05:00:49.530Z", "updated_at": "2025-03-13T05:00:49.530Z" } @@ -216,6 +232,7 @@ "initiated_by": 2, "rarity_icon": "🔷🔷", "rarity_level": 2, + "image": null, "created_at": "2025-03-13T05:00:53.037Z", "updated_at": "2025-03-13T05:00:53.037Z" } @@ -229,6 +246,7 @@ "initiated_by": 2, "rarity_icon": "🔷🔷", "rarity_level": 2, + "image": null, "created_at": "2025-03-13T05:02:36.926Z", "updated_at": "2025-03-13T05:02:36.926Z" } @@ -242,6 +260,7 @@ "initiated_by": 2, "rarity_icon": "🔷🔷", "rarity_level": 2, + "image": null, "created_at": "2025-03-13T05:03:39.241Z", "updated_at": "2025-03-13T05:03:39.241Z" } @@ -255,6 +274,7 @@ "initiated_by": 2, "rarity_icon": "🔷🔷", "rarity_level": 2, + "image": null, "created_at": "2025-03-13T05:05:22.304Z", "updated_at": "2025-03-13T05:05:22.304Z" } @@ -268,6 +288,7 @@ "initiated_by": 2, "rarity_icon": "🔷🔷", "rarity_level": 2, + "image": null, "created_at": "2025-03-13T05:08:31.437Z", "updated_at": "2025-03-13T05:08:31.437Z" } @@ -281,6 +302,7 @@ "initiated_by": 2, "rarity_icon": "🔷🔷", "rarity_level": 2, + "image": null, "created_at": "2025-03-13T05:09:40.853Z", "updated_at": "2025-03-13T05:09:40.853Z" } diff --git a/trades/migrations/0001_initial.py b/trades/migrations/0001_initial.py index afd1cd2..3f6d414 100644 --- a/trades/migrations/0001_initial.py +++ b/trades/migrations/0001_initial.py @@ -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 from django.db import migrations, models @@ -22,6 +22,7 @@ class Migration(migrations.Migration): ('hash', models.CharField(editable=False, max_length=9)), ('rarity_icon', models.CharField(max_length=8, 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)), ('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')), diff --git a/trades/models.py b/trades/models.py index c2b3d10..67765fa 100644 --- a/trades/models.py +++ b/trades/models.py @@ -6,6 +6,21 @@ from cards.models import Card from accounts.models import FriendCode from datetime import timedelta 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): @@ -32,7 +47,7 @@ class TradeOfferAllManager(models.Manager): class TradeOffer(models.Model): objects = TradeOfferManager() - all_offers = TradeOfferAllManager() # New unfiltered manager + all_offers = TradeOfferAllManager() id = models.AutoField(primary_key=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_level = models.IntegerField(null=True) + image = models.ImageField(upload_to='trade_offers/', null=True, blank=True) want_cards = models.ManyToManyField( "cards.Card", related_name='trade_offers_want', @@ -63,11 +79,9 @@ class TradeOffer(models.Model): return f"Want: {want_names} -> Have: {have_names}" def save(self, *args, **kwargs): - is_new = self.pk is None + if not self.hash: + self.hash = generate_tradeoffer_hash() 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): """ @@ -324,11 +338,9 @@ class TradeAcceptance(models.Model): self.save(update_fields=["state"]) def save(self, *args, **kwargs): - is_new = self.pk is None + if not self.hash: + self.hash = generate_tradeacceptance_hash() 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): return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, " diff --git a/trades/urls.py b/trades/urls.py index 9652447..0a056c6 100644 --- a/trades/urls.py +++ b/trades/urls.py @@ -20,7 +20,7 @@ urlpatterns = [ path("my/", TradeOfferMyListView.as_view(), name="trade_offer_my_list"), path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"), path("/", TradeOfferDetailView.as_view(), name="trade_offer_detail"), - path(".png", cache_page(15)(TradeOfferPNGView.as_view()), name="trade_offer_png"), + path(".png", TradeOfferPNGView.as_view(), name="trade_offer_png"), path("delete//", TradeOfferDeleteView.as_view(), name="trade_offer_delete"), path("offer/", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"), path("accept//", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"), diff --git a/trades/views.py b/trades/views.py index 3265264..fe52e48 100644 --- a/trades/views.py +++ b/trades/views.py @@ -599,81 +599,86 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, Upd class TradeOfferPNGView(View): """ 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, - wraps it in a minimal HTML document, and converts it to PNG using Playwright. + This view uses PostgreSQL advisory locks to ensure that only one generation process + 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): 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']) - from trades.templatetags import trade_offer_tags - # Generate context for the SVG template tag. - tag_context = trade_offer_tags.render_trade_offer_png( - {'request': request}, trade_offer, show_friend_code=True - ) + # If the image is already generated and stored, serve it directly. + if trade_offer.image: + trade_offer.image.open() + 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 - image_width = tag_context.get('image_width') - image_height = tag_context.get('image_height') - - html = render_to_string("templatetags/trade_offer_png.html", tag_context) - - # If there's a query parameter 'debug' set to true, render the HTML to the response. - if request.GET.get('debug'): - return HttpResponse(html, content_type="text/html") - - css = render_to_string("static/css/dist/styles.css") - - # Launch Playwright to render the HTML and capture a screenshot. - with sync_playwright() as p: - print("Launching browser") - browser = p.chromium.launch( - headless=True, - args=[ - "--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", - ] + # Acquire PostgreSQL advisory lock to prevent concurrent generation. + from django.db import connection + lock_key = self.get_lock_key(trade_offer.pk) + with connection.cursor() as cursor: + cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key]) + try: + # Double-check if the image was generated while waiting for the lock. + trade_offer.refresh_from_db() + if trade_offer.image: + trade_offer.image.open() + print(f"Serving recently-cached image for trade offer {trade_offer.pk}") + return HttpResponse(trade_offer.image.read(), content_type="image/png") + + print(f"Generating PNG for trade offer {trade_offer.pk}") + # Generate PNG using Playwright as before. + from trades.templatetags import trade_offer_tags + tag_context = trade_offer_tags.render_trade_offer_png( + {'request': request}, trade_offer, show_friend_code=True ) - print("Launched browser, creating context") - context_browser = browser.new_context(viewport={"width": image_width, "height": image_height}) - print("Created context, creating page") - page = context_browser.new_page() - print("Created page, setting content") - - # 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") + image_width = tag_context.get('image_width') + image_height = tag_context.get('image_height') + if not image_width or not image_height: + raise ValueError("Could not determine image dimensions from tag_context") + html = render_to_string("templatetags/trade_offer_png.html", tag_context) - 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): """