from datetime import date, datetime from enum import Enum from uuid import UUID from pydantic import BaseModel, Field, field_validator class ExamTypeEnum(str, Enum): weekly = "weekly" monthly = "monthly" final = "final" class ReviewStatusEnum(str, Enum): careless = "careless" unknown = "unknown" nervous = "nervous" normal = "normal" REVIEW_STATUS_VALUES = {s.value for s in ReviewStatusEnum} class WrongQuestionStatusEnum(str, Enum): pending = "pending" ocr_done = "ocr_done" solved = "solved" failed = "failed" class WrongQuestionCategoryEnum(str, Enum): regular = "regular" olympiad = "olympiad" class AIProviderEnum(str, Enum): ollama = "ollama" openai = "openai" class SchoolLevelEnum(str, Enum): junior_high = "junior_high" senior_high = "senior_high" class TokenResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" class UserRegister(BaseModel): username: str = Field(min_length=3, max_length=64) password: str = Field(min_length=6, max_length=128) class UserLogin(BaseModel): username: str password: str class RefreshRequest(BaseModel): refresh_token: str class UserOut(BaseModel): id: UUID username: str is_superuser: bool = False created_at: datetime model_config = {"from_attributes": True} class PublicSettingsOut(BaseModel): registration_enabled: bool class AppFeaturesOut(BaseModel): ai_review_enabled: bool class SystemSettingsOut(BaseModel): registration_enabled: bool ai_review_enabled: bool = True ai_provider: AIProviderEnum ollama_base_url: str | None = None ollama_model: str | None = None openai_base_url: str | None = None openai_model: str | None = None openai_api_key_set: bool = False ocr_service_url: str | None = None updated_at: datetime model_config = {"from_attributes": True} class SystemSettingsUpdate(BaseModel): registration_enabled: bool | None = None ai_review_enabled: bool | None = None ai_provider: AIProviderEnum | None = None ollama_base_url: str | None = None ollama_model: str | None = None openai_base_url: str | None = None openai_model: str | None = None openai_api_key: str | None = None ocr_service_url: str | None = None class AdminProfileUpdate(BaseModel): username: str | None = Field(default=None, min_length=3, max_length=64) current_password: str | None = None password: str | None = Field(default=None, min_length=6, max_length=128) class AdminUserCreate(BaseModel): username: str = Field(min_length=3, max_length=64) password: str = Field(min_length=6, max_length=128) class AdminUserPasswordUpdate(BaseModel): password: str = Field(min_length=6, max_length=128) class AdminUserOut(BaseModel): id: UUID username: str is_superuser: bool created_at: datetime model_config = {"from_attributes": True} class StudentCreate(BaseModel): name: str = Field(min_length=1, max_length=64) school_level: SchoolLevelEnum = SchoolLevelEnum.junior_high grade: str | None = None class_name: str | None = None school_name: str | None = Field(default=None, max_length=128) class StudentUpdate(BaseModel): name: str | None = None school_level: SchoolLevelEnum | None = None grade: str | None = None class_name: str | None = None school_name: str | None = Field(default=None, max_length=128) class StudentOut(BaseModel): id: UUID name: str school_level: SchoolLevelEnum grade: str | None class_name: str | None school_name: str | None has_avatar: bool = False created_at: datetime model_config = {"from_attributes": True} @classmethod def from_student(cls, student) -> "StudentOut": return cls( id=student.id, name=student.name, school_level=SchoolLevelEnum(student.school_level.value), grade=student.grade, class_name=student.class_name, school_name=student.school_name, has_avatar=bool(getattr(student, "avatar_path", None)), created_at=student.created_at, ) class SubjectOut(BaseModel): id: int name: str model_config = {"from_attributes": True} class ScoreInput(BaseModel): subject_id: int total_score: float obtained_score: float review_statuses: list[ReviewStatusEnum] = [] @field_validator("total_score") @classmethod def validate_total(cls, v: float) -> float: if v <= 0: raise ValueError("总分必须大于0") return v @field_validator("obtained_score") @classmethod def validate_obtained(cls, v: float, info) -> float: total = info.data.get("total_score") if total is not None and v > total: raise ValueError("得分不能大于总分") if v < 0: raise ValueError("得分不能为负") return v @field_validator("review_statuses") @classmethod def validate_review_statuses(cls, v: list[ReviewStatusEnum]) -> list[ReviewStatusEnum]: seen: set[str] = set() unique: list[ReviewStatusEnum] = [] for item in v: key = item.value if isinstance(item, ReviewStatusEnum) else str(item) if key not in seen: seen.add(key) unique.append(item if isinstance(item, ReviewStatusEnum) else ReviewStatusEnum(key)) return unique class ScoreOut(BaseModel): id: UUID subject_id: int subject_name: str | None = None total_score: float obtained_score: float ratio: float review_statuses: list[ReviewStatusEnum] = [] model_config = {"from_attributes": True} class ExamCreate(BaseModel): exam_type: ExamTypeEnum exam_date: date title: str | None = None scores: list[ScoreInput] = [] class ExamUpdate(BaseModel): exam_type: ExamTypeEnum | None = None exam_date: date | None = None title: str | None = None scores: list[ScoreInput] | None = None class ReviewScoreInput(BaseModel): subject_id: int review_statuses: list[ReviewStatusEnum] = [] class ExamReviewUpdate(BaseModel): reviews: list[ReviewScoreInput] = [] class ReviewInsightRequest(BaseModel): subject_name: str = Field(..., min_length=1, max_length=32) class ReviewInsightResponse(BaseModel): insight: str class ExamOut(BaseModel): id: UUID exam_type: ExamTypeEnum exam_date: date title: str | None created_at: datetime scores: list[ScoreOut] = [] model_config = {"from_attributes": True} class TrendPoint(BaseModel): exam_id: UUID exam_type: ExamTypeEnum exam_date: date title: str | None ratio: float ratio_percent: float delta: float | None = None delta_percent: float | None = None is_volatile: bool = False direction: str | None = None class TrendResponse(BaseModel): subject_id: int subject_name: str threshold: float points: list[TrendPoint] class WrongQuestionOut(BaseModel): id: UUID student_id: UUID subject_id: int subject_name: str | None = None category: WrongQuestionCategoryEnum image_path: str ocr_raw_text: str | None question_text: str | None solution_approach: str | None = None solution_text: str | None mark_regions: list[dict] | None = None has_annotated_image: bool = False has_cropped_image: bool = False error_message: str | None = None status: WrongQuestionStatusEnum created_at: datetime model_config = {"from_attributes": True} class WrongQuestionUpdate(BaseModel): question_text: str | None = None solution_approach: str | None = None solution_text: str | None = None subject_id: int | None = None class CompositionStatusEnum(str, Enum): pending = "pending" generating = "generating" done = "done" failed = "failed" class CompositionInputModeEnum(str, Enum): manual = "manual" ocr = "ocr" class CompositionCreate(BaseModel): topic: str = Field(..., min_length=1, max_length=4000) input_mode: CompositionInputModeEnum = CompositionInputModeEnum.manual class CompositionOcrOut(BaseModel): text: str class CompositionOut(BaseModel): id: UUID student_id: UUID topic: str input_mode: CompositionInputModeEnum writing_plan: str | None = None sample_essay: str | None = None error_message: str | None = None status: CompositionStatusEnum created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class BackupInfoOut(BaseModel): filename: str size_bytes: int created_at: datetime