|
2 | 2 |
|
3 | 3 | import csv |
4 | 4 | import io |
| 5 | +from datetime import datetime, timezone |
5 | 6 | from typing import Literal |
6 | 7 |
|
7 | 8 | from fastapi import APIRouter, Depends, Query, Response |
| 9 | +from reportlab.lib import colors |
| 10 | +from reportlab.lib.pagesizes import letter |
| 11 | +from reportlab.lib.styles import getSampleStyleSheet |
| 12 | +from reportlab.lib.units import inch |
| 13 | +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle |
8 | 14 | from sqlalchemy.ext.asyncio import AsyncSession |
9 | 15 |
|
10 | 16 | from app.auth.dependencies import get_current_user |
@@ -53,15 +59,15 @@ async def get_metrics( |
53 | 59 |
|
54 | 60 | @router.get("/traceability-matrix/export") |
55 | 61 | async def export_traceability_matrix( |
56 | | - format: Literal["csv", "json"] = Query(..., description="Export format: csv or json"), |
| 62 | + format: Literal["csv", "json", "pdf"] = Query(..., description="Export format: csv, json, or pdf"), |
57 | 63 | db: AsyncSession = Depends(get_db), |
58 | 64 | current_user: User = Depends(get_current_user), |
59 | 65 | ): |
60 | 66 | """ |
61 | | - Export the traceability matrix in CSV or JSON format. |
| 67 | + Export the traceability matrix in CSV, JSON, or PDF format. |
62 | 68 |
|
63 | 69 | Args: |
64 | | - format: Export format (csv or json) |
| 70 | + format: Export format (csv, json, or pdf) |
65 | 71 |
|
66 | 72 | Returns: |
67 | 73 | File download with appropriate content type |
@@ -144,3 +150,146 @@ async def export_traceability_matrix( |
144 | 150 | media_type="application/json", |
145 | 151 | headers={"Content-Disposition": "attachment; filename=traceability_matrix.json"}, |
146 | 152 | ) |
| 153 | + |
| 154 | + elif format == "pdf": |
| 155 | + # Build PDF using reportlab |
| 156 | + buffer = io.BytesIO() |
| 157 | + doc = SimpleDocTemplate(buffer, pagesize=letter, topMargin=0.75 * inch, bottomMargin=0.75 * inch) |
| 158 | + styles = getSampleStyleSheet() |
| 159 | + elements = [] |
| 160 | + |
| 161 | + # Title |
| 162 | + elements.append(Paragraph("BGSTM Traceability Matrix Report", styles["Title"])) |
| 163 | + gen_date = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") |
| 164 | + elements.append(Paragraph(f"Generated: {gen_date}", styles["Normal"])) |
| 165 | + elements.append(Spacer(1, 0.2 * inch)) |
| 166 | + |
| 167 | + # Coverage Summary |
| 168 | + elements.append(Paragraph("Coverage Summary", styles["Heading2"])) |
| 169 | + summary_data = [ |
| 170 | + ["Metric", "Value"], |
| 171 | + ["Total Requirements", str(matrix_data.total_requirements)], |
| 172 | + ["Covered Requirements", str(matrix_data.covered_requirements)], |
| 173 | + ["Uncovered Requirements", str(matrix_data.uncovered_requirements)], |
| 174 | + ["Coverage Percentage", f"{matrix_data.coverage_percentage:.1f}%"], |
| 175 | + ["Total Test Cases", str(matrix_data.total_test_cases)], |
| 176 | + ["Orphan Test Cases", str(matrix_data.orphan_test_cases)], |
| 177 | + ] |
| 178 | + summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch]) |
| 179 | + summary_table.setStyle( |
| 180 | + TableStyle( |
| 181 | + [ |
| 182 | + ("BACKGROUND", (0, 0), (-1, 0), colors.grey), |
| 183 | + ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), |
| 184 | + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), |
| 185 | + ("ALIGN", (0, 0), (-1, -1), "LEFT"), |
| 186 | + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), |
| 187 | + ("BACKGROUND", (0, 1), (-1, -1), colors.beige), |
| 188 | + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.lightgrey]), |
| 189 | + ] |
| 190 | + ) |
| 191 | + ) |
| 192 | + elements.append(summary_table) |
| 193 | + elements.append(Spacer(1, 0.2 * inch)) |
| 194 | + |
| 195 | + # Gap Analysis |
| 196 | + uncovered = [r for r in matrix_data.matrix if r.coverage_status == "uncovered"] |
| 197 | + elements.append(Paragraph("Gap Analysis – Uncovered Requirements", styles["Heading2"])) |
| 198 | + if uncovered: |
| 199 | + gap_data = [["External ID", "Requirement Title"]] |
| 200 | + for r in uncovered: |
| 201 | + gap_data.append([r.external_id or "", r.requirement_title]) |
| 202 | + gap_table = Table(gap_data, colWidths=[1.5 * inch, 5 * inch]) |
| 203 | + gap_table.setStyle( |
| 204 | + TableStyle( |
| 205 | + [ |
| 206 | + ("BACKGROUND", (0, 0), (-1, 0), colors.red), |
| 207 | + ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), |
| 208 | + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), |
| 209 | + ("ALIGN", (0, 0), (-1, -1), "LEFT"), |
| 210 | + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), |
| 211 | + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.lightgrey]), |
| 212 | + ] |
| 213 | + ) |
| 214 | + ) |
| 215 | + elements.append(gap_table) |
| 216 | + else: |
| 217 | + elements.append(Paragraph("No uncovered requirements.", styles["Normal"])) |
| 218 | + elements.append(Spacer(1, 0.2 * inch)) |
| 219 | + |
| 220 | + # Full Matrix Table |
| 221 | + elements.append(Paragraph("Requirements Coverage Matrix", styles["Heading2"])) |
| 222 | + matrix_table_data = [["Req ID", "Requirement Title", "Coverage", "Test Case Title", "Link Type", "Score"]] |
| 223 | + for req_coverage in matrix_data.matrix: |
| 224 | + if req_coverage.linked_test_cases: |
| 225 | + for i, tc in enumerate(req_coverage.linked_test_cases): |
| 226 | + score = f"{tc.confidence_score:.2f}" if tc.confidence_score is not None else "—" |
| 227 | + matrix_table_data.append( |
| 228 | + [ |
| 229 | + req_coverage.external_id or "" if i == 0 else "", |
| 230 | + req_coverage.requirement_title if i == 0 else "", |
| 231 | + req_coverage.coverage_status.replace("_", " ").title() if i == 0 else "", |
| 232 | + tc.title, |
| 233 | + tc.link_type, |
| 234 | + score, |
| 235 | + ] |
| 236 | + ) |
| 237 | + else: |
| 238 | + matrix_table_data.append( |
| 239 | + [ |
| 240 | + req_coverage.external_id or "", |
| 241 | + req_coverage.requirement_title, |
| 242 | + req_coverage.coverage_status.replace("_", " ").title(), |
| 243 | + "—", |
| 244 | + "—", |
| 245 | + "—", |
| 246 | + ] |
| 247 | + ) |
| 248 | + col_widths = [1.0 * inch, 2.2 * inch, 1.1 * inch, 2.0 * inch, 0.8 * inch, 0.6 * inch] |
| 249 | + matrix_pdf_table = Table(matrix_table_data, colWidths=col_widths, repeatRows=1) |
| 250 | + matrix_pdf_table.setStyle( |
| 251 | + TableStyle( |
| 252 | + [ |
| 253 | + ("BACKGROUND", (0, 0), (-1, 0), colors.darkblue), |
| 254 | + ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), |
| 255 | + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), |
| 256 | + ("FONTSIZE", (0, 0), (-1, -1), 8), |
| 257 | + ("ALIGN", (0, 0), (-1, -1), "LEFT"), |
| 258 | + ("VALIGN", (0, 0), (-1, -1), "TOP"), |
| 259 | + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), |
| 260 | + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.lightgrey]), |
| 261 | + ] |
| 262 | + ) |
| 263 | + ) |
| 264 | + elements.append(matrix_pdf_table) |
| 265 | + elements.append(Spacer(1, 0.2 * inch)) |
| 266 | + |
| 267 | + # Orphan Test Cases |
| 268 | + if matrix_data.orphans: |
| 269 | + elements.append(Paragraph("Orphan Test Cases (No Linked Requirements)", styles["Heading2"])) |
| 270 | + orphan_data = [["External ID", "Test Case Title"]] |
| 271 | + for orphan in matrix_data.orphans: |
| 272 | + orphan_data.append([orphan.external_id or "", orphan.title]) |
| 273 | + orphan_table = Table(orphan_data, colWidths=[1.5 * inch, 5 * inch]) |
| 274 | + orphan_table.setStyle( |
| 275 | + TableStyle( |
| 276 | + [ |
| 277 | + ("BACKGROUND", (0, 0), (-1, 0), colors.orange), |
| 278 | + ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), |
| 279 | + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), |
| 280 | + ("FONTSIZE", (0, 0), (-1, -1), 8), |
| 281 | + ("ALIGN", (0, 0), (-1, -1), "LEFT"), |
| 282 | + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), |
| 283 | + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.lightgrey]), |
| 284 | + ] |
| 285 | + ) |
| 286 | + ) |
| 287 | + elements.append(orphan_table) |
| 288 | + |
| 289 | + doc.build(elements) |
| 290 | + pdf_bytes = buffer.getvalue() |
| 291 | + return Response( |
| 292 | + content=pdf_bytes, |
| 293 | + media_type="application/pdf", |
| 294 | + headers={"Content-Disposition": "attachment; filename=traceability_matrix.pdf"}, |
| 295 | + ) |
0 commit comments