305 lines
8.3 KiB
Python
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()
|