feat: admin scenario PDF download + business plan export tests

Add /scenarios/<id>/pdf admin route for direct PDF generation via WeasyPrint.
Fix plan.html Jinja template: .items → ['items'] to avoid dict method collision.
Add scenario fixture in conftest.py and comprehensive test suite for business
plan sections, PDF generation, worker handler, and export routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-22 21:27:32 +01:00
parent cac3b3b324
commit 76695f3902
5 changed files with 378 additions and 2 deletions

View File

@@ -1406,6 +1406,48 @@ async def scenario_preview(scenario_id: int):
)
@bp.route("/scenarios/<int:scenario_id>/pdf")
@role_required("admin")
async def scenario_pdf(scenario_id: int):
"""Generate and immediately download a business plan PDF for a published scenario."""
from quart import request as req
from ..businessplan import get_plan_sections
from ..planner.calculator import validate_state
scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
if not scenario:
return jsonify({"error": "Scenario not found."}), 404
lang = req.args.get("lang", "en")
if lang not in ("en", "de"):
lang = "en"
state = validate_state(json.loads(scenario["state_json"]))
d = json.loads(scenario["calc_json"])
sections = get_plan_sections(state, d, lang)
sections["scenario_name"] = scenario["title"]
sections["location"] = scenario.get("location", "")
from pathlib import Path
template_dir = Path(__file__).parent.parent / "templates" / "businessplan"
html_template = (template_dir / "plan.html").read_text()
css = (template_dir / "plan.css").read_text()
from jinja2 import Template
rendered_html = Template(html_template).render(s=sections, css=css)
from weasyprint import HTML
pdf_bytes = HTML(string=rendered_html).write_pdf()
slug = scenario["slug"] or f"scenario-{scenario_id}"
filename = f"padel-business-plan-{slug}-{lang}.pdf"
return Response(
pdf_bytes,
mimetype="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# =============================================================================
# Article Management
# =============================================================================

View File

@@ -38,6 +38,8 @@
<td class="mono text-sm">{{ s.created_at[:10] }}</td>
<td class="text-right">
<a href="{{ url_for('admin.scenario_preview', scenario_id=s.id) }}" class="btn-outline btn-sm">Preview</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a>
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

View File

@@ -53,7 +53,7 @@
<tr><th>{{ s.labels.item }}</th><th style="text-align:right">{{ s.labels.amount }}</th><th>{{ s.labels.notes }}</th></tr>
</thead>
<tbody>
{% for item in s.investment.items %}
{% for item in s.investment['items'] %}
<tr>
<td>{{ item.name }}</td>
<td style="text-align:right">{{ item.formatted_amount }}</td>
@@ -94,7 +94,7 @@
<tr><th>{{ s.labels.item }}</th><th style="text-align:right">{{ s.labels.monthly }}</th><th>{{ s.labels.notes }}</th></tr>
</thead>
<tbody>
{% for item in s.operations.items %}
{% for item in s.operations['items'] %}
<tr>
<td>{{ item.name }}</td>
<td style="text-align:right">{{ item.formatted_amount }}</td>