feat: add engagement export service and endpoint (md/csv/pdf)
- New module backend/app/services/export.py with render_engagement_markdown,
render_engagement_csv, render_engagement_pdf, _render_engagement_html helper,
and _export_filename slugifier (NFKD + fallback "unnamed").
- Extend engagements_bp with GET /api/engagements/<int:eid>/export?format=md|csv|pdf,
gated @role_required("admin","redteam"). Returns 400 on missing/unknown format,
404 on unknown engagement, correct Content-Type + Content-Disposition headers.
- Reuses _enrich_techniques and _enrich_tactics from serializers.py; resilient
to MITRE bundle not loaded (returns empty tactics, no crash).
- Adds weasyprint>=60.0 to backend/requirements.txt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,19 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from flask import Blueprint, Response, g, jsonify, request
|
||||
|
||||
from backend.app.auth import login_required, role_required
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import Engagement, EngagementStatus
|
||||
from backend.app.models.simulation import Simulation
|
||||
from backend.app.serializers import serialize_engagement
|
||||
from backend.app.services.export import (
|
||||
_export_filename,
|
||||
render_engagement_csv,
|
||||
render_engagement_markdown,
|
||||
render_engagement_pdf,
|
||||
)
|
||||
|
||||
engagements_bp = Blueprint("engagements", __name__, url_prefix="/api/engagements")
|
||||
|
||||
@@ -156,3 +163,48 @@ def delete_engagement(engagement_id: int):
|
||||
db.session.delete(engagement)
|
||||
db.session.commit()
|
||||
return "", 204
|
||||
|
||||
|
||||
@engagements_bp.get("/<int:eid>/export")
|
||||
@role_required("admin", "redteam")
|
||||
def export_engagement(eid: int):
|
||||
engagement = db.session.get(Engagement, eid)
|
||||
if engagement is None:
|
||||
return jsonify({"error": "Engagement not found"}), 404
|
||||
|
||||
fmt = request.args.get("format", "").strip().lower()
|
||||
if fmt not in ("md", "csv", "pdf"):
|
||||
return jsonify({"error": "format must be one of: md, csv, pdf"}), 400
|
||||
|
||||
simulations = (
|
||||
Simulation.query.filter_by(engagement_id=eid)
|
||||
.order_by(Simulation.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if fmt == "md":
|
||||
body = render_engagement_markdown(engagement, simulations)
|
||||
filename = _export_filename(engagement, "md")
|
||||
return Response(
|
||||
body,
|
||||
mimetype="text/markdown; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
if fmt == "csv":
|
||||
body = render_engagement_csv(engagement, simulations)
|
||||
filename = _export_filename(engagement, "csv")
|
||||
return Response(
|
||||
body,
|
||||
mimetype="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
# pdf
|
||||
body_bytes = render_engagement_pdf(engagement, simulations)
|
||||
filename = _export_filename(engagement, "pdf")
|
||||
return Response(
|
||||
body_bytes,
|
||||
mimetype="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user