from __future__ import annotations import re from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.models import AVATAR_ANIMALS, AVATAR_COLORS, GENDER_OPTIONS NICKNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-. ]{1,46}[A-Za-z0-9]$|^[A-Za-z0-9]{2,48}$") class AuthConfigOut(BaseModel): auth0_enabled: bool auth0_domain: str auth0_client_id: str auth0_audience: str dev_auth_enabled: bool admin_unclaimed: bool class DevLoginIn(BaseModel): nickname: str = Field(..., min_length=2, max_length=48) email: str | None = Field(default=None, max_length=255) as_moderator: bool = False class TokenSessionOut(BaseModel): access_token: str token_type: str = "bearer" user: UserOut class UserOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int auth_sub: str email: str | None nickname: str first_name: str last_name: str age: int | None gender: str gender_custom: str | None avatar_animal: str avatar_color: str public_website_consent: bool consent_at: datetime | None onboarded: bool is_admin: bool is_banned: bool ban_reason: str | None created_at: datetime class UserUpdateIn(BaseModel): nickname: str | None = Field(default=None, min_length=2, max_length=48) first_name: str | None = Field(default=None, max_length=80) last_name: str | None = Field(default=None, max_length=80) age: int | None = Field(default=None, ge=4, le=120) clear_age: bool = False gender: str | None = None gender_custom: str | None = Field(default=None, max_length=80) avatar_animal: str | None = None avatar_color: str | None = None public_website_consent: bool | None = None @field_validator("nickname") @classmethod def validate_nickname(cls, v: str | None) -> str | None: if v is None: return None cleaned = v.strip() if not NICKNAME_RE.match(cleaned): raise ValueError( "Nickname must be 2 to 48 characters using letters, numbers, spaces, hyphens, or underscores." ) return cleaned @field_validator("gender") @classmethod def validate_gender(cls, v: str | None) -> str | None: if v is not None and v not in GENDER_OPTIONS: raise ValueError(f"Gender must be one of: {', '.join(GENDER_OPTIONS)}") return v @field_validator("avatar_animal") @classmethod def validate_animal(cls, v: str | None) -> str | None: if v is not None and v not in AVATAR_ANIMALS: raise ValueError(f"Avatar animal must be one of: {', '.join(AVATAR_ANIMALS)}") return v @field_validator("avatar_color") @classmethod def validate_color(cls, v: str | None) -> str | None: if v is not None and v not in AVATAR_COLORS: raise ValueError(f"Avatar color must be one of: {', '.join(AVATAR_COLORS)}") return v class ApiKeyCreateIn(BaseModel): name: str = Field(..., min_length=1, max_length=80) expires_in_days: int | None = Field(default=90, ge=1, le=3650) class ApiKeyOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str key_prefix: str expires_at: datetime | None revoked_at: datetime | None last_used_at: datetime | None created_at: datetime is_active: bool class ApiKeyCreatedOut(ApiKeyOut): raw_key: str class QuestionOptionSchema(BaseModel): id: str = Field(..., min_length=1, max_length=32) text: str = Field(..., min_length=1, max_length=300) class QuestionIn(BaseModel): question_type: Literal["multiple_choice", "write_in"] prompt: str = Field(..., min_length=1, max_length=1000) options: list[QuestionOptionSchema] = Field(default_factory=list) correct_answer: str = Field(..., min_length=1, max_length=500) accepted_answers: list[str] = Field(default_factory=list) @model_validator(mode="after") def check_question_structure(self) -> QuestionIn: self.prompt = self.prompt.strip() self.correct_answer = self.correct_answer.strip() if not self.prompt: raise ValueError("Question prompt cannot be blank.") if not self.correct_answer: raise ValueError("Correct answer cannot be blank.") if self.question_type == "multiple_choice": if len(self.options) < 2 or len(self.options) > 6: raise ValueError("Multiple-choice questions require between 2 and 6 options.") option_ids = [opt.id for opt in self.options] if len(set(option_ids)) != len(option_ids): raise ValueError("Multiple-choice option IDs must be unique.") if self.correct_answer not in option_ids: raise ValueError("Multiple-choice correct_answer must match one of the option IDs.") else: self.options = [] self.accepted_answers = [ ans.strip() for ans in self.accepted_answers if ans and ans.strip() ][:10] return self class QuizCreateIn(BaseModel): title: str = Field(..., min_length=1, max_length=140) description: str = Field(default="", max_length=1000) is_public: bool = True questions: list[QuestionIn] = Field(default_factory=list, max_length=40) @field_validator("title") @classmethod def clean_title(cls, v: str) -> str: cleaned = v.strip() if not cleaned: raise ValueError("Quiz title cannot be empty.") return cleaned class QuizUpdateIn(QuizCreateIn): pass class ScoreSummaryOut(BaseModel): score_count: int total_questions: int score_percent: float completed_at: datetime class GlobalBestScoreOut(BaseModel): score_count: int total_questions: int score_percent: float holder_nickname: str holder_avatar_animal: str holder_avatar_color: str class QuizSummaryOut(BaseModel): id: int share_slug: str title: str description: str is_public: bool author_id: int author_nickname: str author_avatar_animal: str author_avatar_color: str question_count: int quiz_mode: Literal["multiple_choice", "write_in", "mixed", "empty"] upvotes: int downvotes: int user_vote: int my_latest_score: ScoreSummaryOut | None = None my_best_score: ScoreSummaryOut | None = None global_best_score: GlobalBestScoreOut | None = None is_favourited: bool = False created_at: datetime updated_at: datetime class QuestionPlayOut(BaseModel): id: int position: int question_type: Literal["multiple_choice", "write_in"] prompt: str options: list[QuestionOptionSchema] class QuestionAuthorOut(QuestionPlayOut): correct_answer: str accepted_answers: list[str] class QuizDetailOut(QuizSummaryOut): questions: list[QuestionPlayOut] editable_questions: list[QuestionAuthorOut] | None = None class QuizSubmitIn(BaseModel): answers: dict[str, str] = Field(default_factory=dict) class QuestionResultOut(BaseModel): question_id: int prompt: str question_type: Literal["multiple_choice", "write_in"] submitted_answer: str is_correct: bool correct_display: str class QuizSubmitOut(BaseModel): attempt_id: int score_count: int total_questions: int score_percent: float results: list[QuestionResultOut] my_latest_score: ScoreSummaryOut my_best_score: ScoreSummaryOut global_best_score: GlobalBestScoreOut class VoteIn(BaseModel): value: Literal[-1, 0, 1] class VoteOut(BaseModel): quiz_id: int upvotes: int downvotes: int user_vote: int class FavouriteAddIn(BaseModel): quiz_id: int position: int | None = Field(default=None, ge=0) class FavouriteReorderIn(BaseModel): quiz_ids: list[int] class FavouriteItemOut(BaseModel): favourite_id: int position: int quiz: QuizSummaryOut class UserScoreRowOut(BaseModel): quiz_id: int share_slug: str quiz_title: str question_count: int latest_score: ScoreSummaryOut best_score: ScoreSummaryOut global_best_score: GlobalBestScoreOut | None = None attempts_count: int class BanUserIn(BaseModel): reason: str = Field(default="Community guidelines violation", max_length=255) TokenSessionOut.model_rebuild()