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:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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() }}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user