Skip to content

Commit 0043835

Browse files
Implement Export and Reporting features (PDF, CSV exports) - resolves #104"
Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
1 parent bf145ca commit 0043835

10 files changed

Lines changed: 734 additions & 24 deletions

File tree

backend/app/api/analytics.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""API endpoints for Suggestion Analytics"""
22

3+
import csv
4+
import io
35
from typing import Any
46

5-
from fastapi import APIRouter, Depends
7+
from fastapi import APIRouter, Depends, Response
68
from sqlalchemy.ext.asyncio import AsyncSession
79

810
from app.auth.dependencies import get_current_user
11+
from app.crud import traceability as traceability_crud
912
from app.db.session import get_db
1013
from app.models.user import User
1114
from app.services.analytics import SuggestionAnalytics
@@ -83,3 +86,63 @@ async def get_algorithm_comparison(
8386
"""
8487
analytics = SuggestionAnalytics(db)
8588
return await analytics.get_algorithm_comparison()
89+
90+
91+
@router.get("/metrics/export/csv")
92+
async def export_metrics_csv(
93+
db: AsyncSession = Depends(get_db),
94+
current_user: User = Depends(get_current_user),
95+
):
96+
"""
97+
Export system metrics as CSV including coverage stats, suggestion counts by status,
98+
and algorithm breakdown.
99+
"""
100+
metrics = await traceability_crud.get_metrics(db)
101+
102+
output = io.StringIO()
103+
writer = csv.writer(output)
104+
105+
# Coverage stats section
106+
writer.writerow(["Coverage Statistics"])
107+
writer.writerow(["Metric", "Value"])
108+
writer.writerow(["Coverage Percentage", f"{metrics.coverage_percentage:.2f}%"])
109+
writer.writerow(["Total Requirements", metrics.total_requirements])
110+
writer.writerow(["Total Test Cases", metrics.total_test_cases])
111+
writer.writerow(["Total Links", metrics.total_links])
112+
writer.writerow(["Manual Links", metrics.manual_links])
113+
writer.writerow(["AI Suggested Links", metrics.ai_suggested_links])
114+
115+
writer.writerow([]) # Separator
116+
117+
# Suggestion counts by status
118+
writer.writerow(["Suggestion Counts by Status"])
119+
writer.writerow(["Status", "Count"])
120+
writer.writerow(["Total", metrics.total_suggestions])
121+
writer.writerow(["Accepted", metrics.accepted_suggestions])
122+
writer.writerow(["Rejected", metrics.rejected_suggestions])
123+
writer.writerow(["Pending", metrics.pending_suggestions])
124+
writer.writerow(["Suggestion Acceptance Rate", f"{metrics.suggestion_acceptance_rate:.2f}%"])
125+
126+
writer.writerow([]) # Separator
127+
128+
# Algorithm breakdown
129+
writer.writerow(["Algorithm Breakdown"])
130+
writer.writerow(["Algorithm", "Total", "Accepted", "Rejected", "Pending", "Acceptance Rate"])
131+
for algo in metrics.algorithm_breakdown:
132+
writer.writerow(
133+
[
134+
algo.algorithm,
135+
algo.total_suggestions,
136+
algo.accepted_suggestions,
137+
algo.rejected_suggestions,
138+
algo.pending_suggestions,
139+
f"{algo.acceptance_rate:.2f}%",
140+
]
141+
)
142+
143+
csv_content = output.getvalue()
144+
return Response(
145+
content=csv_content,
146+
media_type="text/csv",
147+
headers={"Content-Disposition": "attachment; filename=metrics.csv"},
148+
)

backend/app/api/suggestions.py

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,115 @@
11
"""API endpoints for AI Suggestion Generation"""
22

3-
from typing import Any
3+
import csv
4+
import io
45

5-
from fastapi import APIRouter, Depends, HTTPException, Query, status
6+
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
7+
from sqlalchemy import select
68
from sqlalchemy.ext.asyncio import AsyncSession
9+
from sqlalchemy.orm import selectinload
710

811
from app.ai_suggestions.config import SuggestionConfig
912
from app.ai_suggestions.engine import SuggestionEngine
10-
from app.auth.dependencies import require_admin
13+
from app.auth.dependencies import get_current_user, require_admin
1114
from app.crud.audit_log import create_audit_entry
1215
from app.db.session import get_db
16+
from app.models.suggestion import LinkSuggestion, SuggestionStatus
1317
from app.models.user import User
1418

1519
router = APIRouter()
1620

1721

18-
@router.post("/suggestions/generate", response_model=dict[str, Any])
22+
@router.get("/suggestions/export/csv")
23+
async def export_suggestions_csv(
24+
status: str | None = Query(None, description="Filter by status: pending, accepted, rejected"),
25+
algorithm: str | None = Query(None, description="Filter by algorithm"),
26+
min_score: float | None = Query(None, ge=0.0, le=1.0, description="Minimum confidence score"),
27+
max_score: float | None = Query(None, ge=0.0, le=1.0, description="Maximum confidence score"),
28+
db: AsyncSession = Depends(get_db),
29+
current_user: User = Depends(get_current_user),
30+
):
31+
"""
32+
Export suggestions as CSV with optional filtering.
33+
34+
Supports filtering by status, algorithm, and confidence score range.
35+
"""
36+
query = select(LinkSuggestion).options(
37+
selectinload(LinkSuggestion.requirement),
38+
selectinload(LinkSuggestion.test_case),
39+
)
40+
41+
if status is not None:
42+
try:
43+
status_enum = SuggestionStatus(status.lower())
44+
query = query.where(LinkSuggestion.status == status_enum)
45+
except ValueError:
46+
raise HTTPException(
47+
status_code=400,
48+
detail=f"Invalid status '{status}'. Must be one of: pending, accepted, rejected.",
49+
)
50+
51+
if algorithm is not None:
52+
from app.models.suggestion import SuggestionMethod
53+
54+
try:
55+
method_enum = SuggestionMethod(algorithm.lower())
56+
query = query.where(LinkSuggestion.suggestion_method == method_enum)
57+
except ValueError:
58+
raise HTTPException(
59+
status_code=400,
60+
detail=f"Invalid algorithm '{algorithm}'.",
61+
)
62+
63+
if min_score is not None:
64+
query = query.where(LinkSuggestion.similarity_score >= min_score)
65+
66+
if max_score is not None:
67+
query = query.where(LinkSuggestion.similarity_score <= max_score)
68+
69+
result = await db.execute(query)
70+
suggestions = result.scalars().all()
71+
72+
output = io.StringIO()
73+
writer = csv.writer(output)
74+
writer.writerow(
75+
[
76+
"ID",
77+
"Requirement Title",
78+
"Test Case Title",
79+
"Algorithm",
80+
"Confidence Score",
81+
"Status",
82+
"Reviewed By",
83+
"Feedback",
84+
"Created At",
85+
"Reviewed At",
86+
]
87+
)
88+
for s in suggestions:
89+
writer.writerow(
90+
[
91+
str(s.id),
92+
s.requirement.title if s.requirement else "",
93+
s.test_case.title if s.test_case else "",
94+
s.suggestion_method.value if s.suggestion_method else "",
95+
f"{s.similarity_score:.4f}" if s.similarity_score is not None else "",
96+
s.status.value if s.status else "",
97+
s.reviewed_by or "",
98+
s.feedback or "",
99+
s.created_at.isoformat() if s.created_at else "",
100+
s.reviewed_at.isoformat() if s.reviewed_at else "",
101+
]
102+
)
103+
104+
csv_content = output.getvalue()
105+
return Response(
106+
content=csv_content,
107+
media_type="text/csv",
108+
headers={"Content-Disposition": "attachment; filename=suggestions.csv"},
109+
)
110+
111+
112+
@router.post("/suggestions/generate", response_model=dict)
19113
async def generate_suggestions(
20114
algorithm: str | None = Query(
21115
None, description="Algorithm to use: 'tfidf', 'keyword', 'hybrid', or 'llm'. Uses default if not specified."

backend/app/api/traceability.py

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22

33
import csv
44
import io
5+
from datetime import datetime, timezone
56
from typing import Literal
67

78
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
814
from sqlalchemy.ext.asyncio import AsyncSession
915

1016
from app.auth.dependencies import get_current_user
@@ -53,15 +59,15 @@ async def get_metrics(
5359

5460
@router.get("/traceability-matrix/export")
5561
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"),
5763
db: AsyncSession = Depends(get_db),
5864
current_user: User = Depends(get_current_user),
5965
):
6066
"""
61-
Export the traceability matrix in CSV or JSON format.
67+
Export the traceability matrix in CSV, JSON, or PDF format.
6268
6369
Args:
64-
format: Export format (csv or json)
70+
format: Export format (csv, json, or pdf)
6571
6672
Returns:
6773
File download with appropriate content type
@@ -144,3 +150,146 @@ async def export_traceability_matrix(
144150
media_type="application/json",
145151
headers={"Content-Disposition": "attachment; filename=traceability_matrix.json"},
146152
)
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+
)

backend/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ scikit-learn==1.8.0
1515
PyJWT==2.10.1
1616
bcrypt>=4.0.0
1717
email-validator==2.2.0
18+
reportlab==4.4.1
1819

1920
# Optional dependencies for LLM embeddings
2021
# Uncomment the lines below if using LLM-based similarity

0 commit comments

Comments
 (0)