Files
classquiz-ai/classquiz/db/models.py
T
2025-10-25 13:38:33 +02:00

569 lines
18 KiB
Python

# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
#
# SPDX-License-Identifier: MPL-2.0
from __future__ import annotations
import os
import uuid
from datetime import datetime
from typing import Optional, Self
from classquiz.config import redis
import json
import ormar
from ormar import ReferentialAction
from pydantic import (
BaseModel,
Json,
field_validator,
ConfigDict,
RootModel,
ValidationInfo,
)
from enum import Enum
from . import metadata, database
from .quiztivity import QuizTivityPage
from sqlalchemy import func
class UserAuthTypes(Enum):
LOCAL = "LOCAL"
GOOGLE = "GOOGLE"
GITHUB = "GITHUB"
CUSTOM = "CUSTOM"
class User(ormar.Model):
"""
The user model in the database
"""
id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4())
email: str = ormar.String(unique=True, max_length=100)
username: str = ormar.String(unique=True, max_length=100)
password: Optional[str] = ormar.String(max_length=100, nullable=True)
verified: bool = ormar.Boolean(default=False)
verify_key: str = ormar.String(unique=True, max_length=100, nullable=True)
created_at: datetime = ormar.DateTime(default=datetime.now())
auth_type: UserAuthTypes = ormar.Enum(enum_class=UserAuthTypes, default=UserAuthTypes.LOCAL)
google_uid: Optional[str] = ormar.String(unique=True, max_length=255, nullable=True)
avatar: bytes = ormar.LargeBinary(max_length=25000, represent_as_base64_str=True)
github_user_id: int | None = ormar.Integer(nullable=True)
require_password: bool = ormar.Boolean(default=True, nullable=False)
backup_code: str = ormar.String(max_length=64, min_length=64, nullable=False, default=os.urandom(32).hex())
totp_secret: str = ormar.String(max_length=32, min_length=32, nullable=True, default=None)
storage_used: int = ormar.BigInteger(nullable=False, default=0, minimum=0)
ormar_config = ormar.OrmarConfig(
tablename="users",
metadata=metadata,
database=database,
)
model_config = ConfigDict(use_enum_values=True)
class FidoCredentials(ormar.Model):
pk: int = ormar.Integer(autoincrement=True, primary_key=True)
id: bytes = ormar.LargeBinary(max_length=256)
public_key: bytes = ormar.LargeBinary(max_length=256)
sign_count: int = ormar.Integer()
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
ormar_config = ormar.OrmarConfig(
tablename="fido_credentials",
metadata=metadata,
database=database,
)
class ApiKey(ormar.Model):
key: str = ormar.String(max_length=48, min_length=48, primary_key=True)
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
ormar_config = ormar.OrmarConfig(
tablename="api_keys",
metadata=metadata,
database=database,
)
class UserSession(ormar.Model):
"""
The user session model for user-sessions
"""
id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4())
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
session_key: str = ormar.String(unique=True, max_length=64)
created_at: datetime = ormar.DateTime(default=datetime.now())
ip_address: str = ormar.String(max_length=100, nullable=True)
user_agent: str = ormar.String(max_length=255, nullable=True)
last_seen: datetime = ormar.DateTime(default=datetime.now())
ormar_config = ormar.OrmarConfig(
tablename="user_sessions",
metadata=metadata,
database=database,
)
class ABCDQuizAnswer(BaseModel):
right: bool
answer: str
color: str | None = None
class RangeQuizAnswer(BaseModel):
min: int
max: int
min_correct: int
max_correct: int
class VotingQuizAnswer(BaseModel):
answer: str
image: str | None = None
color: str | None = None
class QuizQuestionType(str, Enum):
ABCD = "ABCD"
RANGE = "RANGE"
VOTING = "VOTING"
SLIDE = "SLIDE"
TEXT = "TEXT"
ORDER = "ORDER"
CHECK = "CHECK"
class TextQuizAnswer(BaseModel):
answer: str
case_sensitive: bool
class QuizQuestion(BaseModel):
question: str
time: str # in Secs
type: None | QuizQuestionType = QuizQuestionType.ABCD
answers: list[ABCDQuizAnswer] | RangeQuizAnswer | list[TextQuizAnswer] | list[VotingQuizAnswer] | str
image: str | None = None
hide_results: bool | None = False
@field_validator("answers")
def answers_not_none_if_abcd_type(cls, v, info: ValidationInfo):
if info.data["type"] == QuizQuestionType.ABCD and not isinstance(v[0], ABCDQuizAnswer):
raise ValueError("Answers can't be none if type is ABCD")
if info.data["type"] == QuizQuestionType.RANGE and not isinstance(v, RangeQuizAnswer):
raise ValueError("Answer must be from type RangeQuizAnswer if type is RANGE")
if info.data["type"] == QuizQuestionType.VOTING and not isinstance(v[0], VotingQuizAnswer):
raise ValueError("Answer must be from type VotingQuizAnswer if type is VOTING")
if info.data["type"] == QuizQuestionType.TEXT and not isinstance(v[0], TextQuizAnswer):
raise ValueError("Answer must be from type TextQuizAnswer if type is TEXT")
if info.data["type"] == QuizQuestionType.ORDER and not isinstance(v[0], VotingQuizAnswer):
raise ValueError("Answer must be from type VotingQuizAnswer if type is ORDER")
if info.data["type"] == QuizQuestionType.SLIDE and not isinstance(v, str):
raise ValueError("Answer must be from type SlideElement if type is SLIDE")
if info.data["type"] == QuizQuestionType.CHECK and not isinstance(v[0], ABCDQuizAnswer):
raise ValueError("Answers can't be none if type is CHECK")
return v
class QuizInput(BaseModel):
public: bool | None = False
title: str
description: str
cover_image: str | None = None
background_color: str | None = None
questions: list[QuizQuestion]
background_image: str | None = None
class Quiz(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4(), nullable=False, unique=True)
public: bool = ormar.Boolean(default=False)
title: str = ormar.Text()
description: str = ormar.Text(nullable=True)
created_at: datetime = ormar.DateTime(default=datetime.now())
updated_at: datetime = ormar.DateTime(default=datetime.now())
user_id: uuid.UUID = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False)
imported_from_kahoot: Optional[bool] = ormar.Boolean(default=False, nullable=True)
cover_image: Optional[str] = ormar.Text(nullable=True, unique=False)
background_color: str | None = ormar.Text(nullable=True, unique=False)
background_image: str | None = ormar.Text(nullable=True, unique=False)
kahoot_id: uuid.UUID | None = ormar.UUID(nullable=True, default=None)
likes: int = ormar.Integer(nullable=False, default=0, server_default="0")
dislikes: int = ormar.Integer(nullable=False, default=0, server_default="0")
plays: int = ormar.Integer(nullable=False, default=0, server_default="0")
views: int = ormar.Integer(nullable=False, default=0, server_default="0")
mod_rating: int | None = ormar.SmallInteger(nullable=True)
ormar_config = ormar.OrmarConfig(
tablename="quiz",
metadata=metadata,
database=database,
)
class InstanceData(ormar.Model):
instance_id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4(), nullable=False, unique=True)
ormar_config = ormar.OrmarConfig(
tablename="instance_data",
metadata=metadata,
database=database,
)
class Token(BaseModel):
"""
For JWT
"""
access_token: str
token_type: str
class TokenData(BaseModel):
"""
For JWT
"""
email: str | None = None
class PlayGame(BaseModel):
quiz_id: uuid.UUID | str
description: str
user_id: uuid.UUID
title: str
questions: list[QuizQuestion]
game_id: uuid.UUID
game_pin: str
started: bool = False
captcha_enabled: bool = False
cover_image: str | None = None
game_mode: str | None = None
current_question: int = -1
background_color: str | None = None
background_image: str | None = None
custom_field: str | None = None
question_show: bool = False
@classmethod
async def get_from_redis(self, game_pin: str) -> Self:
redis_data: str = await redis.get(f"game:{game_pin}")
data = self.model_validate_json(redis_data)
return data
async def save(self, game_pin: str, ex: int = 7200):
await redis.set(f"game:{game_pin}", self.model_dump_json(), ex=ex)
def to_player_data(self) -> dict:
return (
{
**json.loads(self.model_dump_json(exclude={"quiz_id", "questions", "user_id"})),
"question_count": len(self.questions),
},
)
class GamePlayer(BaseModel):
username: str
sid: str | None = None
async def to_player_stack(self, game_pin: str):
await redis.sadd(
f"game_session:{game_pin}:players",
self.model_dump_json(),
)
class GameAnswer2(BaseModel):
username: str
right: bool
answer: str
class GameAnswer1(BaseModel):
id: int
answers: list[GameAnswer2]
class GameSession(BaseModel):
admin: str
game_id: str
# players: list[GamePlayer | None]
answers: list[GameAnswer1 | None]
@classmethod
async def get_from_redis(self, game_pin: str) -> Self:
redis_data = await redis.get(f"game_session:{game_pin}")
return self.model_validate_json(redis_data)
async def save(self, game_pin: str, ex: int = 7200):
await redis.set(
f"game_session:{game_pin}",
self.model_dump_json(),
ex=7200,
)
class UpdatePassword(BaseModel):
old_password: str
new_password: str
class AnswerData(BaseModel):
username: str
answer: str
right: bool
time_taken: float # In milliseconds
score: int
class AnswerDataList(RootModel):
root: list[AnswerData]
def __iter__(self):
return iter(self.root)
def __getitem__(self, item):
return self.root(item)
def append(self, item):
self.root.append(item)
def __len__(self) -> int:
return len(self.root)
@classmethod
async def get_redis_or_empty(self, game_pin: str, question_number: str) -> Self:
redis_res = await redis.get(f"game_session:{game_pin}:{question_number}")
if redis_res is None:
return self([])
else:
return self.model_validate_json(redis_res)
class GameInLobby(BaseModel):
game_pin: str
quiz_title: str
game_id: uuid.UUID
# skipcq: PY-W0069
# class UserProfileLinks(ormar.Model):
# id: int = ormar.Integer(primary_key=True, autoincrement=True)
# user: Optional[User] = ormar.ForeignKey(User)
# github_username: str | None = ormar.Text(nullable=True)
# reddit_username: str | None = ormar.Text(nullable=True)
# kahoot_user_id: str | None = ormar.Text(nullable=True)
class GameResults(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz, ondelete=ReferentialAction.CASCADE)
user: uuid.UUID | User = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
timestamp: datetime = ormar.DateTime(default=datetime.now(), nullable=False)
player_count: int = ormar.Integer(nullable=False, default=0)
note: str | None = ormar.Text(nullable=True)
answers: Json[list[AnswerData]] = ormar.JSON(True)
player_scores: Json[dict[str, str]] = ormar.JSON(nullable=True)
custom_field_data: Json[dict[str, str]] | None = ormar.JSON(nullable=True)
title: str = ormar.Text(nullable=False)
description: str = ormar.Text(nullable=False)
questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False)
ormar_config = ormar.OrmarConfig(
tablename="game_results",
metadata=metadata,
database=database,
)
class QuizTivityInput(BaseModel):
title: str
pages: list[QuizTivityPage]
class QuizTivity(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
title: str = ormar.Text(nullable=False)
created_at: datetime = ormar.DateTime(nullable=False, server_default=func.now())
user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
pages: list[QuizTivityPage] = ormar.JSON(nullable=False)
ormar_config = ormar.OrmarConfig(
tablename="quiztivitys",
metadata=metadata,
database=database,
)
class QuizTivityShare(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
name: str | None = ormar.Text(nullable=True)
expire_at: datetime | None = ormar.DateTime(nullable=True)
quiztivity: QuizTivity | None = ormar.ForeignKey(QuizTivity, ondelete=ReferentialAction.CASCADE)
user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
ormar_config = ormar.OrmarConfig(
tablename="quiztivityshares",
metadata=metadata,
database=database,
)
class OnlyId(BaseModel):
id: uuid.UUID
class PublicQuizTivityShare(BaseModel):
id: uuid.UUID
name: str | None = None
expire_in: int | None = None
quiztivity: OnlyId
user: OnlyId
@classmethod
def from_db_model(cls, data: QuizTivityShare):
expire_in = None
if data.expire_at is not None:
expire_in = int((data.expire_at - datetime.now()).seconds / 60)
return cls(
id=data.id,
name=data.name,
expire_in=expire_in,
quiztivity=OnlyId(id=data.quiztivity.id),
user=OnlyId(id=data.user.id),
)
class StorageItem(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
uploaded_at: datetime = ormar.DateTime(nullable=False, default=datetime.now())
mime_type: str = ormar.Text(nullable=False)
hash: bytes | None = ormar.LargeBinary(nullable=True, min_length=16, max_length=16)
user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.SET_NULL)
size: int = ormar.BigInteger(nullable=False)
storage_path: str | None = ormar.Text(nullable=True)
deleted_at: datetime | None = ormar.DateTime(nullable=True, default=None)
quiztivities: list[QuizTivity] | None = ormar.ManyToMany(QuizTivity)
quizzes: list[Quiz] | None = ormar.ManyToMany(Quiz)
alt_text: str | None = ormar.Text(default=None, nullable=True)
filename: str | None = ormar.Text(default=None, nullable=True)
thumbhash: str | None = ormar.Text(default=None, nullable=True)
server: str | None = ormar.Text(default=None, nullable=True)
imported: bool = ormar.Boolean(default=False, nullable=True)
ormar_config = ormar.OrmarConfig(
tablename="storage_items",
metadata=metadata,
database=database,
)
class PublicStorageItem(BaseModel):
id: uuid.UUID
uploaded_at: datetime
mime_type: str
hash: str | None = None
size: int
deleted_at: datetime | None = None
alt_text: str | None = None
filename: str | None = None
thumbhash: str | None = None
server: str | None = None
imported: bool
@classmethod
def from_db_model(cls, data: StorageItem):
hash_data = None
if data.hash is not None:
hash_data = data.hash.hex()
return cls(
id=data.id,
uploaded_at=data.uploaded_at,
mime_type=data.mime_type,
hash=hash_data,
size=data.size,
deleted_at=data.deleted_at,
alt_text=data.alt_text,
filename=data.filename,
thumbhash=data.thumbhash,
server=data.server,
imported=data.imported,
)
class PrivateStorageItem(PublicStorageItem):
quizzes: list[OnlyId]
quiztivities: list[OnlyId]
@classmethod
def from_db_model(cls, data: StorageItem):
hash_data = None
if data.hash is not None:
hash_data = data.hash.hex()
quiztivities = []
quizzes = []
for quiz in data.quizzes:
quizzes.append(OnlyId(id=quiz.id))
for quiztivity in data.quiztivities:
quiztivities.append(OnlyId(id=quiztivity.id))
return cls(
id=data.id,
uploaded_at=data.uploaded_at,
mime_type=data.mime_type,
hash=hash_data,
size=data.size,
deleted_at=data.deleted_at,
alt_text=data.alt_text,
filename=data.filename,
quiztivities=quiztivities,
quizzes=quizzes,
thumbhash=data.thumbhash,
server=data.server,
imported=data.imported,
)
class UpdateStorageItem(BaseModel):
filename: str | None = None
alt_text: str | None = None
class Controller(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
user: uuid.UUID | User = ormar.ForeignKey(User)
secret_key: str = ormar.String(nullable=False, max_length=24, min_length=24)
player_name: str = ormar.Text(nullable=False)
last_seen: datetime | None = ormar.DateTime(nullable=True)
first_seen: datetime | None = ormar.DateTime(nullable=True)
name: str = ormar.Text(nullable=False)
os_version: str | None = ormar.Text(nullable=True)
wanted_os_version: str = ormar.Text(nullable=True, default=None)
ormar_config = ormar.OrmarConfig(
tablename="controller",
metadata=metadata,
database=database,
)
class Rating(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
user: uuid.UUID | User = ormar.ForeignKey(User)
positive: bool = ormar.Boolean(nullable=False)
created_at: datetime = ormar.DateTime(nullable=False, server_default=func.now())
quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz)
ormar_config = ormar.OrmarConfig(
tablename="rating",
metadata=metadata,
database=database,
)