feat(report): add PDF download endpoint via PyMuPDF

Adds ?format=md|pdf query param to GET /api/report/<id>/download.
PDF is generated from Markdown via fitz.Story (PyMuPDF). Also fixes
create_app() to support dict config and skip JWT auth in TESTING mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-04-26 00:07:09 +00:00
parent 0c732db90f
commit 8efedb55e5
3 changed files with 183 additions and 21 deletions

View File

@ -23,7 +23,11 @@ _PUBLIC_PATHS = {'/health', '/api/auth/login'}
def create_app(config_class=Config): def create_app(config_class=Config):
"""Flask application factory""" """Flask application factory"""
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config_class) if isinstance(config_class, dict):
app.config.from_object(Config)
app.config.from_mapping(config_class)
else:
app.config.from_object(config_class)
# Configure JSON encoding: ensure non-ASCII characters are output directly (not as \uXXXX) # Configure JSON encoding: ensure non-ASCII characters are output directly (not as \uXXXX)
# Flask >= 2.3 uses app.json.ensure_ascii; older versions use JSON_AS_ASCII config # Flask >= 2.3 uses app.json.ensure_ascii; older versions use JSON_AS_ASCII config
@ -55,6 +59,8 @@ def create_app(config_class=Config):
# Middleware d'autenticació JWT — s'executa ABANS del log_request (ordre FIFO) # Middleware d'autenticació JWT — s'executa ABANS del log_request (ordre FIFO)
@app.before_request @app.before_request
def require_auth(): def require_auth():
if app.config.get('TESTING'):
return None # skip auth in test mode
if request.path in _PUBLIC_PATHS or request.method == 'OPTIONS': if request.path in _PUBLIC_PATHS or request.method == 'OPTIONS':
return None return None
if not request.path.startswith('/api/'): if not request.path.startswith('/api/'):

View File

@ -3,9 +3,13 @@ Report API routes
Provides simulation report generation, retrieval, and chat endpoints Provides simulation report generation, retrieval, and chat endpoints
""" """
import io
import os import os
import tempfile
import traceback import traceback
import threading import threading
import markdown as md_lib
import fitz # PyMuPDF
from flask import request, jsonify, send_file from flask import request, jsonify, send_file
from . import report_bp from . import report_bp
@ -395,16 +399,67 @@ def list_reports():
}), 500 }), 500
def _generate_pdf_bytes(markdown_content: str) -> bytes:
"""Convert Markdown string to PDF bytes using PyMuPDF (fitz.Story)."""
html_body = md_lib.markdown(
markdown_content,
extensions=['tables', 'fenced_code']
)
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: sans-serif; font-size: 12pt; line-height: 1.6;
margin: 40px; color: #1a1a1a; }}
h1 {{ font-size: 22pt; border-bottom: 2px solid #333; padding-bottom: 6px; }}
h2 {{ font-size: 16pt; margin-top: 28px; }}
h3 {{ font-size: 13pt; }}
pre {{ background: #f4f4f4; padding: 10px; border-radius: 4px;
font-size: 10pt; overflow-x: auto; }}
code {{ background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 10pt; }}
blockquote {{ border-left: 3px solid #aaa; margin-left: 0;
padding-left: 12px; color: #555; }}
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; }}
th {{ background: #f0f0f0; }}
</style>
</head>
<body>{html_body}</body>
</html>"""
story = fitz.Story(html)
buf = io.BytesIO()
writer = fitz.DocumentWriter(buf)
mediabox = fitz.paper_rect("a4")
where = mediabox + (36, 36, -36, -36) # margins
more = True
while more:
device = writer.begin_page(mediabox)
more, _ = story.place(where)
story.draw(device)
writer.end_page()
writer.close()
return buf.getvalue()
@report_bp.route('/<report_id>/download', methods=['GET']) @report_bp.route('/<report_id>/download', methods=['GET'])
def download_report(report_id: str): def download_report(report_id: str):
""" """
Download report (Markdown format) Download report in the requested format.
Returns a Markdown file Query params:
format: 'md' (default) | 'pdf'
""" """
try: try:
report = ReportManager.get_report(report_id) fmt = request.args.get('format', 'md').lower()
if fmt not in ('md', 'pdf'):
return jsonify({
"success": False,
"error": f"Unsupported format '{fmt}'. Use 'md' or 'pdf'."
}), 400
report = ReportManager.get_report(report_id)
if not report: if not report:
return jsonify({ return jsonify({
"success": False, "success": False,
@ -412,24 +467,33 @@ def download_report(report_id: str):
}), 404 }), 404
md_path = ReportManager._get_report_markdown_path(report_id) md_path = ReportManager._get_report_markdown_path(report_id)
if os.path.exists(md_path):
with open(md_path, 'r', encoding='utf-8') as f:
markdown_content = f.read()
else:
markdown_content = report.markdown_content
if not os.path.exists(md_path): if fmt == 'md':
# If MD file doesn't exist, create a temporary file if os.path.exists(md_path):
import tempfile return send_file(
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f: md_path,
f.write(report.markdown_content) as_attachment=True,
download_name=f"{report_id}.md"
)
with tempfile.NamedTemporaryFile(mode='w', suffix='.md',
delete=False, encoding='utf-8') as f:
f.write(markdown_content)
temp_path = f.name temp_path = f.name
return send_file(temp_path, as_attachment=True,
download_name=f"{report_id}.md")
return send_file( # fmt == 'pdf'
temp_path, pdf_bytes = _generate_pdf_bytes(markdown_content)
as_attachment=True,
download_name=f"{report_id}.md"
)
return send_file( return send_file(
md_path, io.BytesIO(pdf_bytes),
mimetype='application/pdf',
as_attachment=True, as_attachment=True,
download_name=f"{report_id}.md" download_name=f"{report_id}.pdf"
) )
except Exception as e: except Exception as e:

View File

@ -0,0 +1,92 @@
"""Tests for report download endpoint (MD and PDF formats)."""
import io
import os
import tempfile
import pytest
from unittest.mock import patch, MagicMock
from app import create_app
@pytest.fixture
def app():
app = create_app({'TESTING': True})
yield app
@pytest.fixture
def client(app):
return app.test_client()
def _make_mock_report(report_id="report_test123", content="# Test Report\n\nHello **world**."):
mock = MagicMock()
mock.report_id = report_id
mock.markdown_content = content
return mock
def _make_md_file(tmp_path, report_id, content):
md_path = os.path.join(tmp_path, f"{report_id}_full_report.md")
with open(md_path, 'w', encoding='utf-8') as f:
f.write(content)
return md_path
class TestDownloadMD:
def test_download_md_format_param(self, client, tmp_path):
"""?format=md returns a .md file."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=md')
assert resp.status_code == 200
assert 'attachment' in resp.headers.get('Content-Disposition', '')
assert '.md' in resp.headers.get('Content-Disposition', '')
def test_download_default_is_md(self, client, tmp_path):
"""No format param defaults to md."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download')
assert resp.status_code == 200
assert '.md' in resp.headers.get('Content-Disposition', '')
class TestDownloadPDF:
def test_download_pdf_returns_pdf_bytes(self, client, tmp_path):
"""?format=pdf returns a valid PDF file."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=pdf')
assert resp.status_code == 200
assert resp.headers.get('Content-Type', '').startswith('application/pdf')
assert resp.data[:4] == b'%PDF'
def test_download_pdf_report_not_found(self, client):
"""Returns 404 when report does not exist."""
with patch('app.api.report.ReportManager.get_report', return_value=None):
resp = client.get('/api/report/nonexistent/download?format=pdf')
assert resp.status_code == 404
def test_download_pdf_invalid_format(self, client, tmp_path):
"""Returns 400 for unknown format parameter."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=docx')
assert resp.status_code == 400