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.
codelens/cli.py ADDED
@@ -0,0 +1,297 @@
1
+ """
2
+ Command-line interface for CodeLens batch processing
3
+ """
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import structlog
13
+
14
+ from codelens.core.config import settings
15
+ from codelens.services.batch_processor import BatchProcessingConfig, batch_processor
16
+ from codelens.utils import calculate_grade_letter, format_file_size
17
+
18
+ logger = structlog.get_logger()
19
+
20
+
21
+ async def process_directory_command(args: Any) -> int:
22
+ """Process a directory of code submissions"""
23
+ try:
24
+ print(f"CodeLens Batch Processor v{settings.version}")
25
+ print(f"Processing directory: {args.directory}")
26
+ print("-" * 50)
27
+
28
+ # Configure batch processor
29
+ config = BatchProcessingConfig(
30
+ parallel_processing=not args.sequential,
31
+ max_concurrent=args.max_concurrent,
32
+ skip_unsupported_files=not args.include_unsupported,
33
+ extract_student_info=args.extract_student_info,
34
+ default_language=args.language
35
+ )
36
+
37
+ # Override student ID patterns if provided
38
+ if args.student_id_patterns:
39
+ config.student_id_patterns = args.student_id_patterns.split(',')
40
+
41
+ # Create batch processor with config
42
+ processor = batch_processor.__class__(config)
43
+
44
+ # Process directory
45
+ result = await processor.process_directory(
46
+ directory_path=args.directory,
47
+ assignment_id=args.assignment_id,
48
+ rubric_id=args.rubric_id,
49
+ language=args.language
50
+ )
51
+
52
+ # Display results
53
+ print("\n" + "=" * 60)
54
+ print("BATCH PROCESSING RESULTS")
55
+ print("=" * 60)
56
+
57
+ print(f"Batch ID: {result.batch_id}")
58
+ print(f"Total Files: {result.total_files}")
59
+ print(f"Processed: {result.processed_files}")
60
+ print(f"Failed: {result.failed_files}")
61
+ print(f"Success Rate: {(result.processed_files / result.total_files * 100):.1f}%" if result.total_files > 0 else "0%")
62
+ print(f"Processing Time: {result.processing_time:.2f} seconds")
63
+
64
+ if result.average_score is not None:
65
+ print(f"Average Score: {result.average_score:.1f}%")
66
+ print(f"Average Grade: {calculate_grade_letter(result.average_score)}")
67
+
68
+ # Score distribution
69
+ if result.score_distribution:
70
+ print("\nScore Distribution:")
71
+ for grade_range, count in result.score_distribution.items():
72
+ percentage = (count / result.processed_files * 100) if result.processed_files > 0 else 0
73
+ print(f" {grade_range}: {count} students ({percentage:.1f}%)")
74
+
75
+ # Errors
76
+ if result.errors:
77
+ print("\nErrors:")
78
+ for i, error in enumerate(result.errors[:10], 1): # Show first 10 errors
79
+ print(f" {i}. {error}")
80
+ if len(result.errors) > 10:
81
+ print(f" ... and {len(result.errors) - 10} more errors")
82
+
83
+ # Detailed results
84
+ if args.detailed and result.results:
85
+ print("\n" + "-" * 60)
86
+ print("DETAILED RESULTS")
87
+ print("-" * 60)
88
+
89
+ for i, res in enumerate(result.results[:args.max_details], 1):
90
+ print(f"\n{i}. Submission: {res.submission_id}")
91
+ print(f" Success: {res.success}")
92
+ if res.total_score is not None:
93
+ print(f" Score: {res.total_score:.1f}% ({calculate_grade_letter(res.total_score)})")
94
+ print(f" Issues: {len(res.issues)}")
95
+ if res.metrics:
96
+ print(f" LOC: {res.metrics.lines_of_code}")
97
+ print(f" Complexity: {res.metrics.cyclomatic_complexity}")
98
+ if res.error_message:
99
+ print(f" Error: {res.error_message}")
100
+
101
+ # Save results to file if requested
102
+ if args.output:
103
+ output_data = {
104
+ "batch_id": result.batch_id,
105
+ "summary": {
106
+ "total_files": result.total_files,
107
+ "processed_files": result.processed_files,
108
+ "failed_files": result.failed_files,
109
+ "processing_time": result.processing_time,
110
+ "average_score": result.average_score,
111
+ "score_distribution": result.score_distribution
112
+ },
113
+ "results": [
114
+ {
115
+ "submission_id": r.submission_id,
116
+ "success": r.success,
117
+ "total_score": r.total_score,
118
+ "issues_count": len(r.issues),
119
+ "metrics": r.metrics.dict() if r.metrics else None,
120
+ "error_message": r.error_message
121
+ }
122
+ for r in result.results
123
+ ],
124
+ "errors": result.errors
125
+ }
126
+
127
+ with open(args.output, 'w') as f:
128
+ json.dump(output_data, f, indent=2, default=str)
129
+
130
+ print(f"\nResults saved to: {args.output}")
131
+
132
+ return 0 if result.success else 1
133
+
134
+ except Exception as e:
135
+ logger.error("Directory processing failed", error=str(e))
136
+ print(f"Error: {str(e)}", file=sys.stderr)
137
+ return 1
138
+
139
+
140
+ async def analyze_single_file(args: Any) -> int:
141
+ """Analyze a single code file"""
142
+ try:
143
+ file_path = Path(args.file)
144
+
145
+ if not file_path.exists():
146
+ print(f"Error: File {args.file} does not exist", file=sys.stderr)
147
+ return 1
148
+
149
+ print(f"Analyzing file: {file_path}")
150
+ print(f"File size: {format_file_size(file_path.stat().st_size)}")
151
+ print("-" * 40)
152
+
153
+ # Read file content
154
+ with open(file_path, encoding='utf-8') as f:
155
+ code = f.read()
156
+
157
+ # Process as single file batch
158
+ files_data = [{
159
+ "code": code,
160
+ "path": str(file_path),
161
+ "student_id": args.student_id,
162
+ "student_name": args.student_name
163
+ }]
164
+
165
+ result = await batch_processor.process_files_list(
166
+ files_data=files_data,
167
+ assignment_id=args.assignment_id,
168
+ rubric_id=args.rubric_id,
169
+ language=args.language
170
+ )
171
+
172
+ if result.results:
173
+ res = result.results[0]
174
+ print(f"Analysis completed in {result.processing_time:.2f} seconds")
175
+ print(f"Success: {res.success}")
176
+
177
+ if res.total_score is not None:
178
+ print(f"Score: {res.total_score:.1f}% ({calculate_grade_letter(res.total_score)})")
179
+
180
+ if res.metrics:
181
+ print("\nCode Metrics:")
182
+ print(f" Lines of Code: {res.metrics.lines_of_code}")
183
+ print(f" Cyclomatic Complexity: {res.metrics.cyclomatic_complexity}")
184
+ print(f" Functions: {res.metrics.function_count}")
185
+ print(f" Classes: {res.metrics.class_count}")
186
+
187
+ if res.issues:
188
+ print(f"\nIssues Found ({len(res.issues)}):")
189
+ for i, issue in enumerate(res.issues[:10], 1): # Show first 10 issues
190
+ print(f" {i}. Line {issue.line}: {issue.message} ({issue.severity.value})")
191
+ if len(res.issues) > 10:
192
+ print(f" ... and {len(res.issues) - 10} more issues")
193
+
194
+ if res.error_message:
195
+ print(f"\nError: {res.error_message}")
196
+
197
+ return 0 if result.success else 1
198
+
199
+ except Exception as e:
200
+ logger.error("File analysis failed", error=str(e))
201
+ print(f"Error: {str(e)}", file=sys.stderr)
202
+ return 1
203
+
204
+
205
+ def create_parser() -> argparse.ArgumentParser:
206
+ """Create command-line argument parser"""
207
+ parser = argparse.ArgumentParser(
208
+ description="CodeLens - Automated Code Analysis for Educational Use",
209
+ formatter_class=argparse.RawDescriptionHelpFormatter,
210
+ epilog="""
211
+ Examples:
212
+ # Process a directory of Python submissions
213
+ python -m codelens.cli batch /path/to/submissions --language python
214
+
215
+ # Process with specific rubric and assignment
216
+ python -m codelens.cli batch /path/to/submissions --rubric-id 1 --assignment-id 5
217
+
218
+ # Analyze a single file
219
+ python -m codelens.cli analyze submission.py --student-id cs123456
220
+
221
+ # Generate detailed report with output file
222
+ python -m codelens.cli batch /submissions --detailed --output results.json
223
+ """
224
+ )
225
+
226
+ subparsers = parser.add_subparsers(dest='command', help='Available commands')
227
+
228
+ # Batch processing command
229
+ batch_parser = subparsers.add_parser('batch', help='Process directory of code submissions')
230
+ batch_parser.add_argument('directory', help='Directory containing code submissions')
231
+ batch_parser.add_argument('--language', default='python',
232
+ help='Programming language (default: python)')
233
+ batch_parser.add_argument('--assignment-id', type=int,
234
+ help='Assignment ID for database storage')
235
+ batch_parser.add_argument('--rubric-id', type=int,
236
+ help='Rubric ID for grading')
237
+ batch_parser.add_argument('--sequential', action='store_true',
238
+ help='Process files sequentially instead of parallel')
239
+ batch_parser.add_argument('--max-concurrent', type=int, default=5,
240
+ help='Maximum concurrent processing (default: 5)')
241
+ batch_parser.add_argument('--include-unsupported', action='store_true',
242
+ help='Include unsupported file types')
243
+ batch_parser.add_argument('--no-extract-student-info', dest='extract_student_info',
244
+ action='store_false', default=True,
245
+ help='Disable automatic student info extraction')
246
+ batch_parser.add_argument('--student-id-patterns',
247
+ help='Comma-separated regex patterns for student ID extraction')
248
+ batch_parser.add_argument('--detailed', action='store_true',
249
+ help='Show detailed results for each submission')
250
+ batch_parser.add_argument('--max-details', type=int, default=20,
251
+ help='Maximum detailed results to show (default: 20)')
252
+ batch_parser.add_argument('--output', '-o',
253
+ help='Output file for results (JSON format)')
254
+
255
+ # Single file analysis command
256
+ analyze_parser = subparsers.add_parser('analyze', help='Analyze a single code file')
257
+ analyze_parser.add_argument('file', help='Code file to analyze')
258
+ analyze_parser.add_argument('--language', default='python',
259
+ help='Programming language (default: python)')
260
+ analyze_parser.add_argument('--student-id', help='Student ID')
261
+ analyze_parser.add_argument('--student-name', help='Student name')
262
+ analyze_parser.add_argument('--assignment-id', type=int,
263
+ help='Assignment ID for database storage')
264
+ analyze_parser.add_argument('--rubric-id', type=int,
265
+ help='Rubric ID for grading')
266
+
267
+ return parser
268
+
269
+
270
+ async def main() -> int:
271
+ """Main CLI entry point"""
272
+ parser = create_parser()
273
+ args = parser.parse_args()
274
+
275
+ if not args.command:
276
+ parser.print_help()
277
+ return 1
278
+
279
+ if args.command == 'batch':
280
+ return await process_directory_command(args)
281
+ elif args.command == 'analyze':
282
+ return await analyze_single_file(args)
283
+ else:
284
+ parser.print_help()
285
+ return 1
286
+
287
+
288
+ if __name__ == "__main__":
289
+ try:
290
+ exit_code = asyncio.run(main())
291
+ sys.exit(exit_code)
292
+ except KeyboardInterrupt:
293
+ print("\nInterrupted by user", file=sys.stderr)
294
+ sys.exit(1)
295
+ except Exception as e:
296
+ print(f"Unexpected error: {str(e)}", file=sys.stderr)
297
+ sys.exit(1)
@@ -0,0 +1 @@
1
+ """Core application components"""
@@ -0,0 +1,91 @@
1
+ """
2
+ Application configuration management
3
+ """
4
+
5
+
6
+ from pydantic import BaseModel, Field
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class AnalyzerConfig(BaseModel):
11
+ """Configuration for code analysis tools"""
12
+
13
+ # Python analyzers
14
+ ruff_enabled: bool = True
15
+ ruff_config: str | None = None
16
+ mypy_enabled: bool = True
17
+ mypy_config: str | None = None
18
+
19
+ # Analysis options
20
+ max_complexity: int = 10
21
+ max_line_length: int = 88
22
+ check_type_hints: bool = True
23
+ check_docstrings: bool = True
24
+
25
+ # Execution limits
26
+ execution_timeout: int = 30 # seconds
27
+ memory_limit: str = "128m" # Docker memory limit
28
+ cpu_limit: str = "0.5" # Docker CPU limit
29
+
30
+
31
+ class SimilarityConfig(BaseModel):
32
+ """Configuration for similarity detection"""
33
+
34
+ enabled: bool = True
35
+ threshold: float = 0.8 # Similarity threshold for flagging
36
+ methods: list[str] = ["ast_structural", "token_based"]
37
+
38
+ # AI-generated baseline comparison
39
+ use_ai_baselines: bool = True
40
+ ai_baseline_count: int = 5
41
+
42
+
43
+ class DatabaseConfig(BaseModel):
44
+ """Database configuration"""
45
+
46
+ url: str = "sqlite+aiosqlite:///./codelens.db"
47
+ echo: bool = False # SQL logging
48
+ pool_size: int = 5
49
+ max_overflow: int = 10
50
+
51
+
52
+ class Settings(BaseSettings):
53
+ """Application settings"""
54
+
55
+ model_config = SettingsConfigDict(
56
+ env_file=".env",
57
+ env_file_encoding="utf-8",
58
+ case_sensitive=False
59
+ )
60
+
61
+ # Application
62
+ app_name: str = "CodeLens"
63
+ debug: bool = False
64
+ version: str = "0.1.0"
65
+
66
+ # API
67
+ api_prefix: str = "/api/v1"
68
+ host: str = "localhost"
69
+ port: int = 8000
70
+ docs_enabled: bool = True # Always enable Swagger docs for educational tool
71
+
72
+ # Security
73
+ secret_key: str = Field(default="your-secret-key-change-in-production")
74
+ access_token_expire_minutes: int = 30
75
+
76
+ # Analysis configuration
77
+ analyzer: AnalyzerConfig = Field(default_factory=AnalyzerConfig)
78
+ similarity: SimilarityConfig = Field(default_factory=SimilarityConfig)
79
+ database: DatabaseConfig = Field(default_factory=DatabaseConfig)
80
+
81
+ # Docker settings
82
+ docker_enabled: bool = True
83
+ docker_image: str = "python:3.11-slim"
84
+
85
+ # File limits
86
+ max_file_size: int = 1024 * 1024 # 1MB
87
+ max_files_per_batch: int = 100
88
+
89
+
90
+ # Global settings instance
91
+ settings = Settings()
@@ -0,0 +1 @@
1
+ """Database configuration and connection"""
@@ -0,0 +1,57 @@
1
+ """
2
+ Database configuration and connection management
3
+ """
4
+
5
+ from typing import AsyncGenerator
6
+
7
+ import structlog
8
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
9
+ from sqlalchemy.orm import DeclarativeBase
10
+
11
+ from codelens.core.config import settings
12
+
13
+ logger = structlog.get_logger()
14
+
15
+ # Create async engine
16
+ engine = create_async_engine(
17
+ settings.database.url,
18
+ echo=settings.database.echo,
19
+ pool_size=settings.database.pool_size,
20
+ max_overflow=settings.database.max_overflow,
21
+ )
22
+
23
+ # Create async session maker
24
+ AsyncSessionLocal = async_sessionmaker(
25
+ bind=engine,
26
+ class_=AsyncSession,
27
+ expire_on_commit=False,
28
+ )
29
+
30
+
31
+ class Base(DeclarativeBase):
32
+ """Base class for all database models"""
33
+ pass
34
+
35
+
36
+ async def get_db() -> AsyncGenerator[AsyncSession, None]:
37
+ """Dependency to get database session"""
38
+ async with AsyncSessionLocal() as session:
39
+ try:
40
+ yield session
41
+ except Exception as e:
42
+ logger.error("Database session error", error=str(e))
43
+ await session.rollback()
44
+ raise
45
+ finally:
46
+ await session.close()
47
+
48
+
49
+ async def init_db() -> None:
50
+ """Initialize database tables"""
51
+ async with engine.begin() as conn:
52
+ # Import all models to ensure they're registered
53
+
54
+ # Create all tables
55
+ await conn.run_sync(Base.metadata.create_all)
56
+
57
+ logger.info("Database tables initialized")
codelens/main.py ADDED
@@ -0,0 +1,111 @@
1
+ """
2
+ FastAPI application entry point
3
+ """
4
+
5
+ from typing import Any
6
+
7
+ import structlog
8
+ from fastapi import FastAPI
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+
11
+ from codelens.api.routes import analysis, reports, rubrics
12
+ from codelens.core.config import settings
13
+ from codelens.db.database import init_db
14
+
15
+ # Configure structured logging
16
+ structlog.configure(
17
+ processors=[
18
+ structlog.stdlib.filter_by_level,
19
+ structlog.stdlib.add_logger_name,
20
+ structlog.stdlib.add_log_level,
21
+ structlog.stdlib.PositionalArgumentsFormatter(),
22
+ structlog.processors.TimeStamper(fmt="iso"),
23
+ structlog.processors.StackInfoRenderer(),
24
+ structlog.processors.format_exc_info,
25
+ structlog.processors.UnicodeDecoder(),
26
+ structlog.processors.JSONRenderer()
27
+ ],
28
+ context_class=dict,
29
+ logger_factory=structlog.stdlib.LoggerFactory(),
30
+ cache_logger_on_first_use=True,
31
+ )
32
+
33
+ logger = structlog.get_logger()
34
+
35
+
36
+ def create_app() -> FastAPI:
37
+ """Create FastAPI application"""
38
+
39
+ app = FastAPI(
40
+ title=settings.app_name,
41
+ description="Automated Code Analysis & Grading Assistant for Educators",
42
+ version=settings.version,
43
+ debug=settings.debug,
44
+ openapi_url="/openapi.json" if settings.docs_enabled else None,
45
+ docs_url="/docs" if settings.docs_enabled else None,
46
+ redoc_url="/redoc" if settings.docs_enabled else None,
47
+ )
48
+
49
+ # CORS middleware
50
+ app.add_middleware(
51
+ CORSMiddleware,
52
+ allow_origins=["*"] if settings.debug else [],
53
+ allow_credentials=True,
54
+ allow_methods=["*"],
55
+ allow_headers=["*"],
56
+ )
57
+
58
+ # Include routers
59
+ app.include_router(
60
+ analysis.router,
61
+ prefix=f"{settings.api_prefix}/analyze",
62
+ tags=["analysis"]
63
+ )
64
+ app.include_router(
65
+ rubrics.router,
66
+ prefix=f"{settings.api_prefix}/rubrics",
67
+ tags=["rubrics"]
68
+ )
69
+ app.include_router(
70
+ reports.router,
71
+ prefix=f"{settings.api_prefix}/reports",
72
+ tags=["reports"]
73
+ )
74
+
75
+ @app.on_event("startup")
76
+ async def startup_event() -> None:
77
+ """Initialize application on startup"""
78
+ logger.info("Starting CodeLens application")
79
+ await init_db()
80
+ logger.info("Database initialized")
81
+
82
+ @app.on_event("shutdown")
83
+ async def shutdown_event() -> None:
84
+ """Cleanup on shutdown"""
85
+ logger.info("Shutting down CodeLens application")
86
+
87
+ @app.get("/health")
88
+ async def health_check() -> dict[str, Any]:
89
+ """Health check endpoint"""
90
+ return {
91
+ "status": "healthy",
92
+ "version": settings.version,
93
+ "app": settings.app_name
94
+ }
95
+
96
+ return app
97
+
98
+
99
+ # Create app instance
100
+ app = create_app()
101
+
102
+
103
+ if __name__ == "__main__":
104
+ import uvicorn
105
+ uvicorn.run(
106
+ "codelens.main:app",
107
+ host=settings.host,
108
+ port=settings.port,
109
+ reload=settings.debug,
110
+ log_config=None, # Use structlog configuration
111
+ )
@@ -0,0 +1,14 @@
1
+ """Database models"""
2
+
3
+ from .assignments import Assignment, TestCase
4
+ from .reports import AnalysisReport, SimilarityMatch
5
+ from .rubrics import Rubric, RubricCriterion
6
+
7
+ __all__ = [
8
+ "Rubric",
9
+ "RubricCriterion",
10
+ "Assignment",
11
+ "TestCase",
12
+ "AnalysisReport",
13
+ "SimilarityMatch",
14
+ ]
@@ -0,0 +1,105 @@
1
+ """
2
+ Database models for assignments and specifications
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING
7
+
8
+ from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text
9
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
10
+ from sqlalchemy.sql import func
11
+
12
+ from codelens.db.database import Base
13
+
14
+ if TYPE_CHECKING:
15
+ from .reports import AnalysisReport
16
+ from .rubrics import Rubric
17
+
18
+
19
+ class Assignment(Base):
20
+ """Assignment specification and requirements"""
21
+
22
+ __tablename__ = "assignments"
23
+
24
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
25
+ name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
26
+ description: Mapped[str] = mapped_column(Text, nullable=False)
27
+
28
+ # Course information
29
+ course_id: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
30
+ course_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
31
+ semester: Mapped[str | None] = mapped_column(String(20), nullable=True)
32
+
33
+ # Assignment configuration
34
+ language: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
35
+ rubric_id: Mapped[int] = mapped_column(
36
+ Integer, ForeignKey("rubrics.id"), nullable=False, index=True
37
+ )
38
+
39
+ # Requirements and specifications
40
+ requirements: Mapped[dict] = mapped_column(JSON, nullable=False) # Technical requirements
41
+ test_cases: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Expected outputs
42
+ starter_code: Mapped[str | None] = mapped_column(Text, nullable=True)
43
+
44
+ # Similarity checking configuration
45
+ similarity_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
46
+ similarity_threshold: Mapped[float] = mapped_column(nullable=False, default=0.8)
47
+ cross_cohort_check: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
48
+
49
+ # AI baseline configuration
50
+ ai_baselines: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Generated code variants
51
+
52
+ # Deadlines
53
+ due_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
54
+ late_penalty: Mapped[float | None] = mapped_column(nullable=True, default=0.0) # Per day penalty
55
+
56
+ # Metadata
57
+ created_at: Mapped[datetime] = mapped_column(
58
+ DateTime(timezone=True),
59
+ server_default=func.now(),
60
+ nullable=False
61
+ )
62
+ updated_at: Mapped[datetime] = mapped_column(
63
+ DateTime(timezone=True),
64
+ server_default=func.now(),
65
+ onupdate=func.now(),
66
+ nullable=False
67
+ )
68
+
69
+ # Relationships
70
+ rubric: Mapped["Rubric"] = relationship("Rubric", back_populates="assignments")
71
+ reports: Mapped[list["AnalysisReport"]] = relationship(
72
+ "AnalysisReport", back_populates="assignment", cascade="all, delete-orphan"
73
+ )
74
+
75
+
76
+ class TestCase(Base):
77
+ """Test cases for assignment validation"""
78
+
79
+ __tablename__ = "test_cases"
80
+
81
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
82
+ assignment_id: Mapped[int] = mapped_column(
83
+ Integer, ForeignKey("assignments.id"), nullable=False, index=True
84
+ )
85
+
86
+ # Test case details
87
+ name: Mapped[str] = mapped_column(String(100), nullable=False)
88
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
89
+ test_type: Mapped[str] = mapped_column(String(50), nullable=False) # unit, integration, etc
90
+
91
+ # Test configuration
92
+ input_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
93
+ expected_output: Mapped[str | None] = mapped_column(Text, nullable=True)
94
+ test_code: Mapped[str | None] = mapped_column(Text, nullable=True) # Custom test code
95
+
96
+ # Scoring
97
+ points: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
98
+ required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
99
+
100
+ # Metadata
101
+ created_at: Mapped[datetime] = mapped_column(
102
+ DateTime(timezone=True),
103
+ server_default=func.now(),
104
+ nullable=False
105
+ )