from pydantic import BaseModel, EmailStr, Field, field_validator from typing import Optional, List, Dict from datetime import datetime from bson import ObjectId class PyObjectId(ObjectId): """Custom ObjectId type for Pydantic""" @classmethod def __get_validators__(cls): yield cls.validate @classmethod def validate(cls, v): if not ObjectId.is_valid(v): raise ValueError("Invalid ObjectId") return ObjectId(v) @classmethod def __get_pydantic_json_schema__(cls, field_schema): field_schema.update(type="string") # User Models class UserBase(BaseModel): email: EmailStr username: str = Field(..., min_length=3, max_length=50) class UserCreate(UserBase): password: str = Field(..., min_length=6) class UserLogin(BaseModel): email: EmailStr password: str class UserInDB(UserBase): id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id") hashed_password: str created_at: datetime = Field(default_factory=datetime.utcnow) class Config: populate_by_name = True json_encoders = {ObjectId: str} class UserResponse(UserBase): id: str = Field(..., alias="_id") created_at: datetime class Config: populate_by_name = True json_encoders = {ObjectId: str} class Token(BaseModel): access_token: str token_type: str = "bearer" user: UserResponse # Grading Category Model (flexible, not German-specific) class GradingCategory(BaseModel): name: str = Field(..., min_length=1, max_length=100) # e.g., "Written", "Oral", "Homework" weight: float = Field(..., ge=0, le=100) # Percentage weight (0-100) color: Optional[str] = Field(default="#3b82f6") # Hex color for UI # Subject Model class SubjectBase(BaseModel): name: str = Field(..., min_length=1, max_length=100) grading_categories: List[GradingCategory] = Field(..., min_items=1) color: Optional[str] = Field(default="#3b82f6") teacher: Optional[str] = None grade_system: Optional[str] = Field(default="percentage") # "percentage", "german", "us-letter" target_grade: Optional[float] = None target_grade_max: Optional[float] = None @field_validator("grading_categories") @classmethod def validate_weights(cls, categories: List[GradingCategory]) -> List[GradingCategory]: total_weight = sum(cat.weight for cat in categories) if abs(total_weight - 100.0) > 0.01: # Allow small floating point errors raise ValueError(f"Grading category weights must sum to 100%, got {total_weight}%") return categories class SubjectCreate(SubjectBase): pass class SubjectUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) grading_categories: Optional[List[GradingCategory]] = None color: Optional[str] = None teacher: Optional[str] = None grade_system: Optional[str] = None target_grade: Optional[float] = None target_grade_max: Optional[float] = None @field_validator("grading_categories") @classmethod def validate_weights(cls, categories: Optional[List[GradingCategory]]) -> Optional[List[GradingCategory]]: if categories is None: return None total_weight = sum(cat.weight for cat in categories) if abs(total_weight - 100.0) > 0.01: raise ValueError(f"Grading category weights must sum to 100%, got {total_weight}%") return categories class SubjectInDB(SubjectBase): id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id") user_id: str created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) class Config: populate_by_name = True json_encoders = {ObjectId: str} class SubjectResponse(SubjectBase): id: str = Field(..., alias="_id") created_at: datetime updated_at: datetime class Config: populate_by_name = True json_encoders = {ObjectId: str} # Test/Grade Model class GradeBase(BaseModel): subject_id: str category_name: str # Must match one of the subject's grading categories grade: float = Field(..., ge=0) # Grade value (e.g., 1.0 to 6.0 for German, 0-100 for percentage) max_grade: float = Field(..., ge=0) # Maximum possible grade (e.g., 6.0 or 100) weight_in_category: float = Field(default=1.0, ge=0) # Weight within the category (for averaging multiple tests) name: Optional[str] = Field(None, max_length=200) # Test name/description date: datetime = Field(default_factory=datetime.utcnow) notes: Optional[str] = None class GradeCreate(GradeBase): pass class GradeUpdate(BaseModel): category_name: Optional[str] = None grade: Optional[float] = Field(None, ge=0) max_grade: Optional[float] = Field(None, ge=0) weight_in_category: Optional[float] = Field(None, ge=0) name: Optional[str] = Field(None, max_length=200) date: Optional[datetime] = None notes: Optional[str] = None class GradeInDB(GradeBase): id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id") user_id: str created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) class Config: populate_by_name = True json_encoders = {ObjectId: str} class GradeResponse(GradeBase): id: str = Field(..., alias="_id") created_at: datetime updated_at: datetime class Config: populate_by_name = True json_encoders = {ObjectId: str} # Report Period Model class ReportPeriodBase(BaseModel): name: str = Field(..., min_length=1, max_length=100) # e.g., "First Half-Year 2024/2025" start_date: datetime end_date: datetime @field_validator("end_date") @classmethod def validate_dates(cls, end_date: datetime, info) -> datetime: start_date = info.data.get("start_date") if start_date and end_date <= start_date: raise ValueError("end_date must be after start_date") return end_date class ReportPeriodCreate(ReportPeriodBase): pass class ReportPeriodUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) start_date: Optional[datetime] = None end_date: Optional[datetime] = None class ReportPeriodInDB(ReportPeriodBase): id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id") user_id: str created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) class Config: populate_by_name = True json_encoders = {ObjectId: str} class ReportPeriodResponse(ReportPeriodBase): id: str = Field(..., alias="_id") created_at: datetime updated_at: datetime class Config: populate_by_name = True json_encoders = {ObjectId: str} # Teacher Grade Override Model (manual grades from teachers) class TeacherGradeBase(BaseModel): subject_id: str period_id: str grade: float = Field(..., ge=0) # The grade value (e.g., 1.0-6.0 for German, 0-100 for percentage) max_grade: float = Field(..., ge=0) # Maximum possible grade (e.g., 6.0 or 100) notes: Optional[str] = Field(None, max_length=500) class TeacherGradeCreate(TeacherGradeBase): pass class TeacherGradeUpdate(BaseModel): grade: Optional[float] = Field(None, ge=0) max_grade: Optional[float] = Field(None, ge=0) notes: Optional[str] = Field(None, max_length=500) class TeacherGradeInDB(TeacherGradeBase): id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id") user_id: str created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) class Config: populate_by_name = True json_encoders = {ObjectId: str} class TeacherGradeResponse(TeacherGradeBase): id: str = Field(..., alias="_id") created_at: datetime updated_at: datetime class Config: populate_by_name = True json_encoders = {ObjectId: str}