diff --git a/blueprints/client/requests.py b/blueprints/client/requests.py index 80c3417..0142af7 100644 --- a/blueprints/client/requests.py +++ b/blueprints/client/requests.py @@ -1,9 +1,20 @@ +import logging import math import uuid - -from flask import abort, flash, g, redirect, render_template, request, url_for +from io import BytesIO + +from flask import ( + abort, + flash, + g, + redirect, + render_template, + request, + send_file, + url_for, +) from sqlalchemy import String, and_, cast, func, or_, select -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import joinedload, selectinload from blueprints.client._helpers import ( DIETARY_FLAGS, @@ -39,6 +50,8 @@ ) from services.quotes import calculate_quote_totals +logger = logging.getLogger(__name__) + # Filter tabs visible on /client/requests. Each tab maps to one of the # values _derive_request_display_status() returns (or "all"). @@ -59,6 +72,11 @@ QuoteStatus.expired, ) +# Mirror of the cap on caterer/requests.py: refuse to render a quote +# whose line items list is implausibly long. Stops a malicious or +# corrupted row from saturating the WeasyPrint worker. +_MAX_PDF_LINES = 500 + def _derive_request_display_status(qr): """Collapse QR.status + quote presence into a single client-facing code. @@ -542,6 +560,66 @@ def refuse_quote(request_id): flash("Devis refuse.", "info") return redirect(url_for("client.request_detail", request_id=request_id)) + @bp.route("/requests//quote//pdf", methods=["GET"]) + @login_required + @role_required("client_admin", "client_user") + @limiter.limit("20 per minute") + def quote_pdf(request_id, q_id): + """Download a received quote as a server-rendered PDF. + + Mirrors `caterer.quote_pdf` but the scope check goes the other + way: the quote must belong to a request the viewer's company + owns. Reuses `services.quote_pdf.render_quote_pdf` so the file + looks identical to what the client sees in the in-app preview + modal (same template, same totals). + """ + # Lazy import — WeasyPrint pulls Cairo/Pango bindings at import + # time. Same rationale as caterer.quote_pdf. + from services.quote_pdf import render_quote_pdf + + user = g.current_user + db = get_db() + quote = db.scalar( + select(Quote) + .options( + selectinload(Quote.lines), + joinedload(Quote.caterer), + joinedload(Quote.quote_request).options( + joinedload(QuoteRequest.company), + joinedload(QuoteRequest.user), + ), + ) + .where(Quote.id == q_id) + .where(Quote.quote_request_id == request_id) + # Drafts are caterer-only — refuse to serve a brouillon PDF + # to the client even if they guess the URL. Allow-list + # rather than `!= draft` so a future status doesn't leak by + # default. + .where(Quote.status.in_(_QUOTE_RECEIVED_STATUSES)) + ) + # Company-scope check: 404 instead of 403 so we don't leak the + # existence of a quote outside the viewer's perimeter. + if not quote or quote.quote_request.company_id != user.company_id: + abort(404) + if len(quote.lines) > _MAX_PDF_LINES: + abort(413) + + pdf_bytes = render_quote_pdf(quote, quote.quote_request, quote.caterer) + logger.info( + "quote_pdf_downloaded company_id=%s user_id=%s quote_id=%s reference=%s lines=%d", + user.company_id, + user.id, + quote.id, + quote.reference, + len(quote.lines), + ) + return send_file( + BytesIO(pdf_bytes), + mimetype="application/pdf", + as_attachment=True, + download_name=f"devis-{quote.reference}.pdf", + ) + @bp.route("/requests//edit", methods=["GET"]) @login_required @role_required("client_admin", "client_user") diff --git a/templates/caterer/requests/detail.html b/templates/caterer/requests/detail.html index c03dc54..a9eea42 100644 --- a/templates/caterer/requests/detail.html +++ b/templates/caterer/requests/detail.html @@ -400,10 +400,19 @@

Historique avec ce cl

Aperçu du devis

Réf. {{ existing_quote.reference }}

- +
+ {# Server-rendered PDF download — same _pdf_preview.html + partial as the modal so the file matches what's on screen. #} + + + Télécharger + + +
diff --git a/templates/client/requests/detail.html b/templates/client/requests/detail.html index 17e6965..41a7f61 100644 --- a/templates/client/requests/detail.html +++ b/templates/client/requests/detail.html @@ -441,11 +441,22 @@

Aucun devis recu

Aperçu du devis

Réf. {{ quote.reference }}

- +
+ {# Server-rendered PDF — identical content to this preview + (same _pdf_preview.html partial). Goes through the + client.quote_pdf route which company-scopes the + access and reuses services.quote_pdf.render_quote_pdf. #} + + + Télécharger + + +