code-analyser 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,438 @@
1
+ """
2
+ API endpoints for analysis reports and results
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ import structlog
9
+ from fastapi import APIRouter, Depends, HTTPException, Query
10
+ from fastapi import status as http_status
11
+ from pydantic import BaseModel
12
+ from sqlalchemy import and_, desc, func, select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from codelens.db.database import get_db
16
+ from codelens.models import AnalysisReport, Assignment, SimilarityMatch
17
+
18
+ logger = structlog.get_logger()
19
+ router = APIRouter()
20
+
21
+
22
+ class ReportSummary(BaseModel):
23
+ """Summary schema for analysis reports"""
24
+ id: int
25
+ submission_id: str
26
+ assignment_id: int | None = None
27
+ student_id: str | None = None
28
+ student_name: str | None = None
29
+ file_name: str
30
+ language: str
31
+ total_score: float
32
+ max_score: float
33
+ syntax_valid: bool
34
+ analyzed_at: datetime
35
+ status: str
36
+
37
+ class Config:
38
+ from_attributes = True
39
+
40
+
41
+ class ReportDetail(BaseModel):
42
+ """Detailed schema for analysis reports"""
43
+ id: int
44
+ submission_id: str
45
+ assignment_id: int | None = None
46
+ student_id: str | None = None
47
+ student_name: str | None = None
48
+ file_name: str
49
+ file_size: int
50
+ file_hash: str
51
+ language: str
52
+ analysis_version: str
53
+
54
+ # Analysis results
55
+ syntax_valid: bool
56
+ syntax_errors: dict[str, Any] | None = None
57
+ quality_metrics: dict[str, Any]
58
+ test_results: dict[str, Any] | None = None
59
+ grade_breakdown: dict[str, Any]
60
+ total_score: float
61
+ max_score: float
62
+ similarity_results: dict[str, Any] | None = None
63
+ feedback: dict[str, Any]
64
+
65
+ # Metadata
66
+ processing_time: float
67
+ tools_used: dict[str, Any]
68
+ status: str
69
+ analyzed_at: datetime
70
+
71
+ class Config:
72
+ from_attributes = True
73
+
74
+
75
+ class SimilarityMatchResponse(BaseModel):
76
+ """Schema for similarity match results"""
77
+ id: int
78
+ report_id: int
79
+ matched_report_id: int
80
+ similarity_score: float
81
+ similarity_method: str
82
+ matched_sections: dict[str, Any]
83
+ confidence: float
84
+ flagged: bool
85
+ reviewed: bool
86
+ review_decision: str | None = None
87
+ reviewer_notes: str | None = None
88
+ detected_at: datetime
89
+
90
+ class Config:
91
+ from_attributes = True
92
+
93
+
94
+ class AssignmentStatistics(BaseModel):
95
+ """Statistics for an assignment"""
96
+ assignment_id: int
97
+ total_submissions: int
98
+ average_score: float
99
+ median_score: float
100
+ score_distribution: dict[str, int] # Grade ranges
101
+ common_issues: list[dict[str, Any]]
102
+ completion_rate: float
103
+ similarity_flags: int
104
+
105
+
106
+ @router.get("/", response_model=list[ReportSummary])
107
+ async def list_reports(
108
+ assignment_id: int | None = None,
109
+ student_id: str | None = None,
110
+ language: str | None = None,
111
+ status: str | None = None,
112
+ limit: int = Query(50, le=500),
113
+ offset: int = Query(0, ge=0),
114
+ db: AsyncSession = Depends(get_db)
115
+ ) -> list[ReportSummary]:
116
+ """List analysis reports with filtering options"""
117
+ try:
118
+ query = select(AnalysisReport)
119
+
120
+ # Apply filters
121
+ if assignment_id:
122
+ query = query.where(AnalysisReport.assignment_id == assignment_id)
123
+ if student_id:
124
+ query = query.where(AnalysisReport.student_id == student_id)
125
+ if language:
126
+ query = query.where(AnalysisReport.language == language.lower())
127
+ if status:
128
+ query = query.where(AnalysisReport.status == status)
129
+
130
+ # Order by most recent first
131
+ query = query.order_by(desc(AnalysisReport.analyzed_at))
132
+
133
+ # Apply pagination
134
+ query = query.offset(offset).limit(limit)
135
+
136
+ result = await db.execute(query)
137
+ reports = result.scalars().all()
138
+
139
+ logger.info("Listed analysis reports",
140
+ count=len(reports),
141
+ assignment_id=assignment_id,
142
+ student_id=student_id)
143
+
144
+ return [ReportSummary.model_validate(report) for report in reports]
145
+
146
+ except Exception as e:
147
+ logger.error("Failed to list reports", error=str(e))
148
+ raise HTTPException(
149
+ status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
150
+ detail=f"Failed to list reports: {str(e)}"
151
+ ) from None
152
+
153
+
154
+ @router.get("/{report_id}", response_model=ReportDetail)
155
+ async def get_report(
156
+ report_id: int,
157
+ db: AsyncSession = Depends(get_db)
158
+ ) -> ReportDetail:
159
+ """Get detailed analysis report by ID"""
160
+ try:
161
+ result = await db.execute(
162
+ select(AnalysisReport).where(AnalysisReport.id == report_id)
163
+ )
164
+ report = result.scalar_one_or_none()
165
+
166
+ if not report:
167
+ raise HTTPException(
168
+ status_code=http_status.HTTP_404_NOT_FOUND,
169
+ detail=f"Report {report_id} not found"
170
+ ) from None
171
+
172
+ logger.info("Retrieved analysis report", report_id=report_id)
173
+
174
+ return ReportDetail.model_validate(report)
175
+
176
+ except HTTPException:
177
+ raise
178
+ except Exception as e:
179
+ logger.error("Failed to get report", report_id=report_id, error=str(e))
180
+ raise HTTPException(
181
+ status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
182
+ detail=f"Failed to get report: {str(e)}"
183
+ ) from None
184
+
185
+
186
+ @router.get("/submission/{submission_id}", response_model=ReportDetail)
187
+ async def get_report_by_submission(
188
+ submission_id: str,
189
+ db: AsyncSession = Depends(get_db)
190
+ ) -> ReportDetail:
191
+ """Get analysis report by submission ID"""
192
+ try:
193
+ result = await db.execute(
194
+ select(AnalysisReport).where(AnalysisReport.submission_id == submission_id)
195
+ )
196
+ report = result.scalar_one_or_none()
197
+
198
+ if not report:
199
+ raise HTTPException(
200
+ status_code=http_status.HTTP_404_NOT_FOUND,
201
+ detail=f"Report for submission {submission_id} not found"
202
+ ) from None
203
+
204
+ logger.info("Retrieved report by submission", submission_id=submission_id)
205
+
206
+ return ReportDetail.model_validate(report)
207
+
208
+ except HTTPException:
209
+ raise
210
+ except Exception as e:
211
+ logger.error("Failed to get report by submission",
212
+ submission_id=submission_id, error=str(e))
213
+ raise HTTPException(
214
+ status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
215
+ detail=f"Failed to get report: {str(e)}"
216
+ ) from None
217
+
218
+
219
+ @router.get("/assignment/{assignment_id}/statistics", response_model=AssignmentStatistics)
220
+ async def get_assignment_statistics(
221
+ assignment_id: int,
222
+ db: AsyncSession = Depends(get_db)
223
+ ) -> AssignmentStatistics:
224
+ """Get statistics for an assignment"""
225
+ try:
226
+ # Verify assignment exists
227
+ assignment_result = await db.execute(
228
+ select(Assignment).where(Assignment.id == assignment_id)
229
+ )
230
+ assignment = assignment_result.scalar_one_or_none()
231
+
232
+ if not assignment:
233
+ raise HTTPException(
234
+ status_code=http_status.HTTP_404_NOT_FOUND,
235
+ detail=f"Assignment {assignment_id} not found"
236
+ ) from None
237
+
238
+ # Get all reports for the assignment
239
+ reports_result = await db.execute(
240
+ select(AnalysisReport).where(AnalysisReport.assignment_id == assignment_id)
241
+ )
242
+ reports = reports_result.scalars().all()
243
+
244
+ if not reports:
245
+ return AssignmentStatistics(
246
+ assignment_id=assignment_id,
247
+ total_submissions=0,
248
+ average_score=0.0,
249
+ median_score=0.0,
250
+ score_distribution={},
251
+ common_issues=[],
252
+ completion_rate=0.0,
253
+ similarity_flags=0
254
+ )
255
+
256
+ # Calculate statistics
257
+ scores = [report.total_score for report in reports]
258
+ total_submissions = len(reports)
259
+ average_score = sum(scores) / total_submissions
260
+
261
+ # Median score
262
+ sorted_scores = sorted(scores)
263
+ median_score = (
264
+ sorted_scores[total_submissions // 2] if total_submissions % 2 == 1
265
+ else (sorted_scores[total_submissions // 2 - 1] + sorted_scores[total_submissions // 2]) / 2
266
+ )
267
+
268
+ # Score distribution
269
+ score_distribution = {
270
+ "A (90-100)": len([s for s in scores if s >= 90]),
271
+ "B (80-89)": len([s for s in scores if 80 <= s < 90]),
272
+ "C (70-79)": len([s for s in scores if 70 <= s < 80]),
273
+ "D (60-69)": len([s for s in scores if 60 <= s < 70]),
274
+ "F (0-59)": len([s for s in scores if s < 60])
275
+ }
276
+
277
+ # Common issues (simplified - would need more sophisticated analysis)
278
+ common_issues = [
279
+ {"issue": "Style violations", "count": len([r for r in reports if r.quality_metrics.get("style_issues", [])])},
280
+ {"issue": "Type errors", "count": len([r for r in reports if r.quality_metrics.get("type_issues", [])])},
281
+ {"issue": "High complexity", "count": len([r for r in reports if r.quality_metrics.get("complexity", {}).get("cyclomatic", 0) > 10])}
282
+ ]
283
+
284
+ # Completion rate (submissions with tests passing)
285
+ completed = len([r for r in reports if r.test_results and r.test_results.get("passed_tests", 0) > 0])
286
+ completion_rate = (completed / total_submissions) * 100 if total_submissions > 0 else 0
287
+
288
+ # Similarity flags
289
+ similarity_flags_result = await db.execute(
290
+ select(func.count(SimilarityMatch.id)).where(
291
+ and_(
292
+ SimilarityMatch.report_id.in_([r.id for r in reports]),
293
+ SimilarityMatch.flagged
294
+ )
295
+ )
296
+ )
297
+ similarity_flags = similarity_flags_result.scalar() or 0
298
+
299
+ statistics = AssignmentStatistics(
300
+ assignment_id=assignment_id,
301
+ total_submissions=total_submissions,
302
+ average_score=average_score,
303
+ median_score=median_score,
304
+ score_distribution=score_distribution,
305
+ common_issues=common_issues,
306
+ completion_rate=completion_rate,
307
+ similarity_flags=similarity_flags
308
+ )
309
+
310
+ logger.info("Generated assignment statistics",
311
+ assignment_id=assignment_id,
312
+ total_submissions=total_submissions)
313
+
314
+ return statistics
315
+
316
+ except HTTPException:
317
+ raise
318
+ except Exception as e:
319
+ logger.error("Failed to get assignment statistics",
320
+ assignment_id=assignment_id, error=str(e))
321
+ raise HTTPException(
322
+ status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
323
+ detail=f"Failed to get assignment statistics: {str(e)}"
324
+ ) from None
325
+
326
+
327
+ @router.get("/student/{student_id}", response_model=list[ReportSummary])
328
+ async def get_student_reports(
329
+ student_id: str,
330
+ assignment_id: int | None = None,
331
+ limit: int = Query(50, le=500),
332
+ offset: int = Query(0, ge=0),
333
+ db: AsyncSession = Depends(get_db)
334
+ ) -> list[ReportSummary]:
335
+ """Get all reports for a specific student"""
336
+ try:
337
+ query = select(AnalysisReport).where(AnalysisReport.student_id == student_id)
338
+
339
+ if assignment_id:
340
+ query = query.where(AnalysisReport.assignment_id == assignment_id)
341
+
342
+ query = query.order_by(desc(AnalysisReport.analyzed_at))
343
+ query = query.offset(offset).limit(limit)
344
+
345
+ result = await db.execute(query)
346
+ reports = result.scalars().all()
347
+
348
+ logger.info("Retrieved student reports",
349
+ student_id=student_id,
350
+ count=len(reports))
351
+
352
+ return [ReportSummary.model_validate(report) for report in reports]
353
+
354
+ except Exception as e:
355
+ logger.error("Failed to get student reports",
356
+ student_id=student_id, error=str(e))
357
+ raise HTTPException(
358
+ status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
359
+ detail=f"Failed to get student reports: {str(e)}"
360
+ ) from None
361
+
362
+
363
+ @router.get("/similarity/{report_id}", response_model=list[SimilarityMatchResponse])
364
+ async def get_similarity_matches(
365
+ report_id: int,
366
+ db: AsyncSession = Depends(get_db)
367
+ ) -> list[SimilarityMatchResponse]:
368
+ """Get similarity matches for a specific report"""
369
+ try:
370
+ # Verify report exists
371
+ report_result = await db.execute(
372
+ select(AnalysisReport).where(AnalysisReport.id == report_id)
373
+ )
374
+ report = report_result.scalar_one_or_none()
375
+
376
+ if not report:
377
+ raise HTTPException(
378
+ status_code=http_status.HTTP_404_NOT_FOUND,
379
+ detail=f"Report {report_id} not found"
380
+ ) from None
381
+
382
+ # Get similarity matches
383
+ matches_result = await db.execute(
384
+ select(SimilarityMatch).where(
385
+ SimilarityMatch.report_id == report_id
386
+ ).order_by(desc(SimilarityMatch.similarity_score))
387
+ )
388
+ matches = matches_result.scalars().all()
389
+
390
+ logger.info("Retrieved similarity matches",
391
+ report_id=report_id,
392
+ match_count=len(matches))
393
+
394
+ return [SimilarityMatchResponse.model_validate(match) for match in matches]
395
+
396
+ except HTTPException:
397
+ raise
398
+ except Exception as e:
399
+ logger.error("Failed to get similarity matches",
400
+ report_id=report_id, error=str(e))
401
+ raise HTTPException(
402
+ status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
403
+ detail=f"Failed to get similarity matches: {str(e)}"
404
+ ) from None
405
+
406
+
407
+ @router.delete("/{report_id}", status_code=http_status.HTTP_204_NO_CONTENT)
408
+ async def delete_report(
409
+ report_id: int,
410
+ db: AsyncSession = Depends(get_db)
411
+ ) -> None:
412
+ """Delete an analysis report"""
413
+ try:
414
+ result = await db.execute(
415
+ select(AnalysisReport).where(AnalysisReport.id == report_id)
416
+ )
417
+ report = result.scalar_one_or_none()
418
+
419
+ if not report:
420
+ raise HTTPException(
421
+ status_code=http_status.HTTP_404_NOT_FOUND,
422
+ detail=f"Report {report_id} not found"
423
+ ) from None
424
+
425
+ await db.delete(report)
426
+ await db.commit()
427
+
428
+ logger.info("Deleted analysis report", report_id=report_id)
429
+
430
+ except HTTPException:
431
+ raise
432
+ except Exception as e:
433
+ logger.error("Failed to delete report", report_id=report_id, error=str(e))
434
+ await db.rollback()
435
+ raise HTTPException(
436
+ status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
437
+ detail=f"Failed to delete report: {str(e)}"
438
+ ) from None