diff --git a/backend/app/__init__.py b/backend/app/__init__.py index e8bd7e47..ae17f391 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -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/'): diff --git a/backend/app/api/report.py b/backend/app/api/report.py index ae776e79..a5c3148f 100644 --- a/backend/app/api/report.py +++ b/backend/app/api/report.py @@ -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""" + + + + + +{html_body} +""" + + 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('//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({ diff --git a/backend/tests/test_report_download.py b/backend/tests/test_report_download.py new file mode 100644 index 00000000..953df54e --- /dev/null +++ b/backend/tests/test_report_download.py @@ -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