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:
parent
0c732db90f
commit
8efedb55e5
|
|
@ -23,6 +23,10 @@ _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__)
|
||||||
|
if isinstance(config_class, dict):
|
||||||
|
app.config.from_object(Config)
|
||||||
|
app.config.from_mapping(config_class)
|
||||||
|
else:
|
||||||
app.config.from_object(config_class)
|
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)
|
||||||
|
|
@ -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/'):
|
||||||
|
|
|
||||||
|
|
@ -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,25 +467,34 @@ 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
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
|
||||||
f.write(report.markdown_content)
|
|
||||||
temp_path = f.name
|
|
||||||
|
|
||||||
return send_file(
|
|
||||||
temp_path,
|
|
||||||
as_attachment=True,
|
|
||||||
download_name=f"{report_id}.md"
|
|
||||||
)
|
|
||||||
|
|
||||||
return send_file(
|
return send_file(
|
||||||
md_path,
|
md_path,
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name=f"{report_id}.md"
|
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
|
||||||
|
return send_file(temp_path, as_attachment=True,
|
||||||
|
download_name=f"{report_id}.md")
|
||||||
|
|
||||||
|
# fmt == 'pdf'
|
||||||
|
pdf_bytes = _generate_pdf_bytes(markdown_content)
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(pdf_bytes),
|
||||||
|
mimetype='application/pdf',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"{report_id}.pdf"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to download report: {str(e)}")
|
logger.error(f"Failed to download report: {str(e)}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue