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,7 +23,11 @@ _PUBLIC_PATHS = {'/health', '/api/auth/login'}
|
|||
def create_app(config_class=Config):
|
||||
"""Flask application factory"""
|
||||
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)
|
||||
# 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)
|
||||
@app.before_request
|
||||
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':
|
||||
return None
|
||||
if not request.path.startswith('/api/'):
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ Report API routes
|
|||
Provides simulation report generation, retrieval, and chat endpoints
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import traceback
|
||||
import threading
|
||||
import markdown as md_lib
|
||||
import fitz # PyMuPDF
|
||||
from flask import request, jsonify, send_file
|
||||
|
||||
from . import report_bp
|
||||
|
|
@ -395,43 +399,103 @@ def list_reports():
|
|||
}), 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'])
|
||||
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:
|
||||
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:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": t('api.reportNotFound', id=report_id)
|
||||
}), 404
|
||||
|
||||
|
||||
md_path = ReportManager._get_report_markdown_path(report_id)
|
||||
|
||||
if not os.path.exists(md_path):
|
||||
# If MD file doesn't exist, create a temporary file
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(report.markdown_content)
|
||||
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 fmt == 'md':
|
||||
if os.path.exists(md_path):
|
||||
return send_file(
|
||||
md_path,
|
||||
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
|
||||
|
||||
return send_file(
|
||||
temp_path,
|
||||
as_attachment=True,
|
||||
download_name=f"{report_id}.md"
|
||||
)
|
||||
|
||||
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(
|
||||
md_path,
|
||||
io.BytesIO(pdf_bytes),
|
||||
mimetype='application/pdf',
|
||||
as_attachment=True,
|
||||
download_name=f"{report_id}.md"
|
||||
download_name=f"{report_id}.pdf"
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download report: {str(e)}")
|
||||
return jsonify({
|
||||
|
|
|
|||
|
|
@ -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