trymeapp/app/schemas.py

305 lines
8.3 KiB
Python

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()