From d5f8345581fd0838e38a72d2352146e561e1404a Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Fri, 28 Mar 2025 00:42:41 -0700 Subject: [PATCH] Rewrite how trade offer png's are generated to try to reduce system resources. Only render the specific html code necessary and not the entire trade offer details page. --- Dockerfile | 4 +- accounts/migrations/0001_initial.py | 7 +- accounts/templatetags/gravatar.py | 5 +- .../trades/trade_offer_confirm_create.html | 2 +- .../templates/trades/trade_offer_detail.html | 6 +- theme/templatetags/trade_offer_png.html | 146 ++++++++++++++++++ trades/migrations/0001_initial.py | 2 +- trades/templatetags/trade_offer_tags.py | 54 ++++++- trades/views.py | 98 ++++-------- 9 files changed, 239 insertions(+), 85 deletions(-) create mode 100644 theme/templatetags/trade_offer_png.html diff --git a/Dockerfile b/Dockerfile index 03993ae..0f9dcde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ COPY .env.production /code/.env ENV HOME=/code # Install NPM & node.js -RUN apt-get update && apt-get install -y nodejs npm xvfb libnss3 libnspr4 libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libgbm1 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 libcairo-gobject2 libdbus-glib-1-2 libfontconfig1 libfreetype6 libgdk-pixbuf-2.0-0 libgtk-3-0 libharfbuzz0b libpangocairo-1.0-0 libx11-xcb1 libxcb-shm0 libxcursor1 libxi6 libxrender1 libxtst6 libsoup-3.0-0 gstreamer1.0-libav gstreamer1.0-plugins-base gstreamer1.0-plugins-good libegl1 libenchant-2-2 libepoxy0 libevdev2 libgles2 libglx0 libgstreamer-gl1.0-0 libgstreamer-plugins-base1.0-0 libgstreamer1.0-0 libgtk-4-1 libgudev-1.0-0 libharfbuzz-icu0 libhyphen0 libicu72 libjpeg62-turbo liblcms2-2 libmanette-0.2-0 libnotify4 libopengl0 libopenjp2-7 libopus0 libpng16-16 libproxy1v5 libsecret-1-0 libwayland-client0 libwayland-egl1 libwayland-server0 libwebp7 libwebpdemux2 libwoff1 libxml2 libxslt1.1 libatomic1 libevent-2.1-7 libavif16 xvfb fonts-noto-color-emoji fonts-unifont xfonts-scalable fonts-liberation fonts-ipafont-gothic fonts-wqy-zenhei fonts-tlwg-loma-otf fonts-freefont-ttf +RUN apt-get update && apt-get install -y nodejs npm xvfb RUN playwright install-deps && playwright install @@ -37,4 +37,4 @@ RUN python manage.py collectstatic --noinput #RUN python manage.py createcachetable django_cache # Use gunicorn on port 8000 -CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "django_project.wsgi"] +CMD ["gunicorn", "--bind", ":8000", "django_project.wsgi", "--timeout", "300"] diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index da58fb7..142bab2 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 5.1.2 on 2025-03-22 04:08 +# Generated by Django 5.1.2 on 2025-03-28 04:43 +import accounts.models import django.contrib.auth.models import django.contrib.auth.validators import django.db.models.deletion @@ -48,8 +49,8 @@ class Migration(migrations.Migration): name='FriendCode', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('friend_code', models.CharField(max_length=19)), - ('in_game_name', models.CharField(max_length=16)), + ('friend_code', models.CharField(max_length=19, validators=[accounts.models.validate_friend_code])), + ('in_game_name', models.CharField(max_length=14)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='friend_codes', to=settings.AUTH_USER_MODEL)), diff --git a/accounts/templatetags/gravatar.py b/accounts/templatetags/gravatar.py index 3aab3ba..c2005a0 100644 --- a/accounts/templatetags/gravatar.py +++ b/accounts/templatetags/gravatar.py @@ -24,6 +24,7 @@ def gravatar_url(email, size=20): default = "retro" email_hash = gravatar_hash(email) params = urlencode({'d': default, 's': str(size)}) + params = params.replace("&", "&") return f"https://www.gravatar.com/avatar/{email_hash}?{params}" @register.filter @@ -44,7 +45,7 @@ def gravatar(email, size=20): """ url = gravatar_url(email, size) # Return a safe HTML snippet with the image element - html = f'Gravatar' + html = f'Gravatar' return mark_safe(html) @register.filter @@ -55,7 +56,7 @@ def gravatar_no_hover(email, size=20): """ url = gravatar_url(email, size) # Return a safe HTML snippet with the image element - html = f'Gravatar' + html = f'Gravatar' return mark_safe(html) @register.filter diff --git a/theme/templates/trades/trade_offer_confirm_create.html b/theme/templates/trades/trade_offer_confirm_create.html index 26cbac2..1a6827d 100644 --- a/theme/templates/trades/trade_offer_confirm_create.html +++ b/theme/templates/trades/trade_offer_confirm_create.html @@ -17,7 +17,7 @@ {% endfor %} {% endfor %} - {% render_trade_offer dummy_trade_offer False False True %} + {% render_trade_offer dummy_trade_offer True %}
diff --git a/theme/templates/trades/trade_offer_detail.html b/theme/templates/trades/trade_offer_detail.html index 1baf325..0c3253f 100644 --- a/theme/templates/trades/trade_offer_detail.html +++ b/theme/templates/trades/trade_offer_detail.html @@ -7,11 +7,7 @@

Trade Offer Details

- {% if screenshot_mode == "true" %} - {% render_trade_offer object True show_friend_code %} - {% else %} - {% render_trade_offer object False False True %} - {% endif %} + {% render_trade_offer object %}
{% if acceptance_form %}
diff --git a/theme/templatetags/trade_offer_png.html b/theme/templatetags/trade_offer_png.html new file mode 100644 index 0000000..89eb9d4 --- /dev/null +++ b/theme/templatetags/trade_offer_png.html @@ -0,0 +1,146 @@ +{% load gravatar card_badge tailwind_tags %} + + + + + + + + + + \ No newline at end of file diff --git a/trades/migrations/0001_initial.py b/trades/migrations/0001_initial.py index 4821fae..edbc2c6 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-22 04:08 +# Generated by Django 5.1.2 on 2025-03-28 04:43 import django.db.models.deletion from django.db import migrations, models diff --git a/trades/templatetags/trade_offer_tags.py b/trades/templatetags/trade_offer_tags.py index 8156c3c..96a770d 100644 --- a/trades/templatetags/trade_offer_tags.py +++ b/trades/templatetags/trade_offer_tags.py @@ -3,7 +3,7 @@ from django import template register = template.Library() @register.inclusion_tag('templatetags/trade_offer.html', takes_context=True) -def render_trade_offer(context, offer, screenshot_mode=False, show_friend_code=False, expanded=False): +def render_trade_offer(context, offer, expanded=False): """ Renders a trade offer including detailed trade acceptance information. Freezes the through-model querysets to avoid extra DB hits. @@ -34,10 +34,8 @@ def render_trade_offer(context, offer, screenshot_mode=False, show_friend_code=F 'acceptances': acceptances, 'have_cards_available': have_cards_available, 'want_cards_available': want_cards_available, - 'screenshot_mode': screenshot_mode, 'in_game_name': offer.initiated_by.in_game_name, 'friend_code': offer.initiated_by.friend_code, - 'show_friend_code': show_friend_code, 'num_cards_available': len(have_cards_available) + len(want_cards_available), } @@ -74,4 +72,52 @@ def action_button_class(state_value): 'REJECTED_BY_ACCEPTOR': 'btn btn-error', } # Return a default style if the state isn't in the mapping. - return mapping.get(state_value, 'btn btn-outline') \ No newline at end of file + return mapping.get(state_value, 'btn btn-outline') + +@register.inclusion_tag('templatetags/trade_offer_png.html', takes_context=True) +def render_trade_offer_png(context, offer, show_friend_code=False): + trade_offer_have_cards = list(offer.trade_offer_have_cards.all()) + trade_offer_want_cards = list(offer.trade_offer_want_cards.all()) + + have_cards_available = [ + card for card in trade_offer_have_cards + if card.quantity > card.qty_accepted + ] + want_cards_available = [ + card for card in trade_offer_want_cards + if card.quantity > card.qty_accepted + ] + + num_cards = max(len(have_cards_available), len(want_cards_available)) + aspect_ratio = 1.91 + base_height = (round(num_cards / 2) * 56) + 138 + if (len(have_cards_available) + len(want_cards_available)) >= 4: + base_width = (4 * 144) + 96 + else: + base_width = (2 * 144) + 128 + + if base_height > base_width: + image_height = base_height + image_width = int(round(image_height * aspect_ratio)) + 1 + else: + image_width = base_width + image_height = int(round(image_width / aspect_ratio)) + + base_url = context.get('request').build_absolute_uri('/') + + return { + '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, + 'have_cards_available': have_cards_available, + 'want_cards_available': want_cards_available, + 'in_game_name': offer.initiated_by.in_game_name, + 'friend_code': offer.initiated_by.friend_code, + 'show_friend_code': show_friend_code, + 'num_cards_available': len(have_cards_available) + len(want_cards_available), + 'image_width': image_width, + 'image_height': image_height, + 'base_url': base_url, + } \ No newline at end of file diff --git a/trades/views.py b/trades/views.py index 3b0816a..56db7a3 100644 --- a/trades/views.py +++ b/trades/views.py @@ -599,83 +599,47 @@ 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 trade offer detail page, waits for the trade offer element to render, - simulates a click to expand extra badges, and then screenshots only the trade offer element. + 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. """ - def get(self, request, *args, **kwargs): - # For demonstration purposes, get the first trade offer. - # In production, you might want to identify the offer via a URL parameter. - trade_offer = TradeOffer.objects.get(pk=kwargs['pk']) - if not trade_offer: - raise Http404("Trade offer not found") + from django.shortcuts import get_object_or_404 + trade_offer = get_object_or_404(TradeOffer, pk=kwargs['pk']) - # Get the URL for the trade offer detail view. - detail_url = request.build_absolute_uri( - reverse_lazy("trade_offer_detail", kwargs={"pk": trade_offer.pk})+"?screenshot_mode=true" + 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 ) - + # 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) + + # Launch Playwright to render the HTML and capture a screenshot. with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - context = browser.new_context( - viewport={"width": 1280, "height": 800}, + browser = p.chromium.launch( + headless=True, + args=[ + "--disable-gpu", + "--no-sandbox", + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-accelerated-2d-canvas', + '--no-first-run', + '--disable-gpu' + ] ) - - # If the request contains a Django session cookie, - # add it to the browser context to bypass the login screen. - session_cookie = request.COOKIES.get(settings.SESSION_COOKIE_NAME) - if session_cookie: - cookie = { - "name": settings.SESSION_COOKIE_NAME, - "value": session_cookie, - "domain": request.get_host().split(':')[0], - "path": "/", - "httpOnly": True, - "secure": not settings.DEBUG, - } - context.add_cookies([cookie]) - - # Open a new page and navigate to the detail view. - page = context.new_page() - page.goto(detail_url, wait_until="networkidle") - - # Inject CSS to force transparency. - page.add_style_tag(content=""" - html, body, .bg-base-200 { - background-color: rgba(255, 255, 255, 0) !important; - } - """) - - trade_offer_selector = ".trade-offer-card-screenshot" - page.wait_for_selector(trade_offer_selector) - - # Simulate a click on the toggle button within the trade offer element - # to force the extra details (e.g., extra badges) to expand. - # We use a selector that targets the first svg with a "cursor-pointer" class inside the trade offer card. - toggle_selector = f"{trade_offer_selector} svg.cursor-pointer" - try: - toggle = page.query_selector(toggle_selector) - if toggle: - toggle.click() - # Wait for the CSS animation to complete (600ms as in your template) - page.wait_for_timeout(600) - except Exception: - # If the toggle is not found or clicking fails, proceed without expansion. - pass - - # Locate the element containing the trade offer and capture its screenshot. - element = page.query_selector(trade_offer_selector) - if not element: - browser.close() - raise Http404("Trade offer element not found on page") - - png_bytes = element.screenshot(type="png", omit_background=True) + context_browser = browser.new_context(viewport={"width": image_width, "height": image_height}) + page = context_browser.new_page() + page.set_content(html, wait_until="networkidle") + element = page.wait_for_selector(".trade-offer-card-screenshot") + screenshot_bytes = element.screenshot(type="png", omit_background=True) browser.close() - return HttpResponse(png_bytes, content_type="image/png") + return HttpResponse(screenshot_bytes, content_type="image/png") class TradeOfferCreateConfirmView(LoginRequiredMixin, View): """