Merge pull request #409 from mawoka-myblock/testing

This commit is contained in:
Marlon
2024-10-18 12:03:50 +02:00
committed by GitHub
41 changed files with 7885 additions and 5477 deletions
+1
View File
@@ -12,3 +12,4 @@ node_modules/
survey.json survey.json
.coverage .coverage
export_deta.py export_deta.py
target/
+15
View File
@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
//
// SPDX-License-Identifier: MPL-2.0
{
"sqltools.connections": [
{
"previewLimit": 50,
"server": "localhost",
"driver": "PostgreSQL",
"connectString": "postgresql://postgres:mysecretpassword@localhost:5432/classquiz",
"name": "ClassQuiz"
}
]
}
+1 -1
View File
@@ -4,7 +4,7 @@
#* { #* {
:8080 { :8080 {
# tls /home/mawoka/certs/cert.pem /home/mawoka/certs/key.pem # tls /home/mawoka/certs/cert.pem /home/mawoka/certs/key.pem
reverse_proxy /* localhost:3000 reverse_proxy /* localhost:3000
reverse_proxy /api* localhost:8000 reverse_proxy /api* localhost:8000
reverse_proxy /rapidoc* localhost:8000 reverse_proxy /rapidoc* localhost:8000
+2 -1
View File
@@ -17,7 +17,7 @@ python-jose = "*"
alembic = "*" alembic = "*"
email-validator = "*" email-validator = "*"
python-multipart = "*" python-multipart = "*"
pydantic = "1.10.8" pydantic = "*"
redis = "*" redis = "*"
aiohttp = "*" aiohttp = "*"
gunicorn = "*" gunicorn = "*"
@@ -46,6 +46,7 @@ starlette = "*"
pyopenssl = "*" pyopenssl = "*"
python-dotenv = "*" python-dotenv = "*"
webauthn = "==1.*" webauthn = "==1.*"
pydantic-settings = "==2.2.1"
[dev-packages] [dev-packages]
coverage = "*" coverage = "*"
Generated
+1062 -500
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -36,7 +36,7 @@ from classquiz.routers import (
moderation, moderation,
) )
from classquiz.socket_server import sio from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init, telemetry_ping from classquiz.helpers import meilisearch_init
settings = settings() settings = settings()
if settings.sentry_dsn: if settings.sentry_dsn:
@@ -63,7 +63,6 @@ async def startup() -> None:
if not database_.is_connected: if not database_.is_connected:
await database_.connect() await database_.connect()
await meilisearch_init() await meilisearch_init()
await telemetry_ping()
@app.on_event("shutdown") @app.on_event("shutdown")
+17 -19
View File
@@ -7,9 +7,9 @@ from functools import lru_cache
from redis import asyncio as redis_lib from redis import asyncio as redis_lib
import redis as redis_base_lib import redis as redis_base_lib
from pydantic import BaseSettings, RedisDsn, PostgresDsn, BaseModel from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import RedisDsn, PostgresDsn, BaseModel
import meilisearch as MeiliSearch import meilisearch as MeiliSearch
from typing import Optional
from arq import create_pool from arq import create_pool
from arq.connections import RedisSettings, ArqRedis from arq.connections import RedisSettings, ArqRedis
@@ -28,10 +28,13 @@ class Settings(BaseSettings):
Settings class for the shop app. Settings class for the shop app.
""" """
model_config = SettingsConfigDict(
env_file=".env", extra="ignore", env_nested_delimiter="__", env_file_encoding="utf-8"
)
root_address: str = "http://127.0.0.1:8000" root_address: str = "http://127.0.0.1:8000"
redis: RedisDsn = "redis://localhost:6379/0?decode_responses=True" redis: RedisDsn = "redis://localhost:6379/0?decode_responses=True"
skip_email_verification: bool = False skip_email_verification: bool = False
db_url: str | PostgresDsn = "postgresql://postgres:mysecretpassword@localhost:5432/classquiz" db_url: PostgresDsn | str = "postgresql://postgres:mysecretpassword@localhost:5432/classquiz"
hcaptcha_key: str | None = None hcaptcha_key: str | None = None
recaptcha_key: str | None = None recaptcha_key: str | None = None
mail_address: str mail_address: str
@@ -42,13 +45,13 @@ class Settings(BaseSettings):
secret_key: str secret_key: str
access_token_expire_minutes: int = 30 access_token_expire_minutes: int = 30
cache_expiry: int = 86400 cache_expiry: int = 86400
sentry_dsn: str | None sentry_dsn: str | None = None
meilisearch_url: str = "http://127.0.0.1:7700" meilisearch_url: str = "http://127.0.0.1:7700"
meilisearch_index: str = "classquiz" meilisearch_index: str = "classquiz"
google_client_id: Optional[str] google_client_id: str | None = None
google_client_secret: Optional[str] google_client_secret: str | None = None
github_client_id: Optional[str] github_client_id: str | None = None
github_client_secret: Optional[str] github_client_secret: str | None = None
custom_openid_provider: CustomOpenIDProvider | None = None custom_openid_provider: CustomOpenIDProvider | None = None
telemetry_enabled: bool = True telemetry_enabled: bool = True
free_storage_limit: int = 1074000000 free_storage_limit: int = 1074000000
@@ -57,21 +60,16 @@ class Settings(BaseSettings):
registration_disabled: bool = False registration_disabled: bool = False
# storage_backend # storage_backend
storage_backend: str | None = "local" storage_backend: str # either "local" or "s3"
# if storage_backend == "local": # if storage_backend == "local":
storage_path: str | None storage_path: str | None = None
# if storage_backend == "s3": # if storage_backend == "s3":
s3_access_key: str | None s3_access_key: str | None = None
s3_secret_key: str | None s3_secret_key: str | None = None
s3_bucket_name: str = "classquiz" s3_bucket_name: str = "classquiz"
s3_base_url: str | None s3_base_url: str | None = None
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
env_nested_delimiter = "__"
async def initialize_arq(): async def initialize_arq():
@@ -85,7 +83,7 @@ def settings() -> Settings:
return Settings() return Settings()
pool = redis_lib.ConnectionPool().from_url(settings().redis) pool = redis_lib.ConnectionPool().from_url(str(settings().redis))
redis: redis_base_lib.client.Redis = redis_lib.Redis(connection_pool=pool) redis: redis_base_lib.client.Redis = redis_lib.Redis(connection_pool=pool)
arq: ArqRedis = ArqRedis(pool_or_conn=pool) arq: ArqRedis = ArqRedis(pool_or_conn=pool)
+37 -75
View File
@@ -10,7 +10,7 @@ from typing import Optional
import ormar import ormar
from ormar import ReferentialAction from ormar import ReferentialAction
from pydantic import BaseModel, Json, validator from pydantic import BaseModel, Json, validator, RootModel
from enum import Enum from enum import Enum
from . import metadata, database from . import metadata, database
from .quiztivity import QuizTivityPage from .quiztivity import QuizTivityPage
@@ -45,13 +45,7 @@ class User(ormar.Model):
totp_secret: str = ormar.String(max_length=32, min_length=32, nullable=True, default=None) 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) storage_used: int = ormar.BigInteger(nullable=False, default=0, minimum=0)
class Meta: ormar_config = ormar.OrmarConfig(database=database, metadata=metadata, tablename="users")
tablename = "users"
metadata = metadata
database = database
class Config:
use_enum_values = True
class FidoCredentials(ormar.Model): class FidoCredentials(ormar.Model):
@@ -61,20 +55,14 @@ class FidoCredentials(ormar.Model):
sign_count: int = ormar.Integer() sign_count: int = ormar.Integer()
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
class Meta: ormar_config = ormar.OrmarConfig(database=database, metadata=metadata, tablename="fido_credentials")
tablename = "fido_credentials"
metadata = metadata
database = database
class ApiKey(ormar.Model): class ApiKey(ormar.Model):
key: str = ormar.String(max_length=48, min_length=48, primary_key=True) key: str = ormar.String(max_length=48, min_length=48, primary_key=True)
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
class Meta: ormar_config = ormar.OrmarConfig(database=database, metadata=metadata, tablename="api_keys")
tablename = "api_keys"
metadata = metadata
database = database
class UserSession(ormar.Model): class UserSession(ormar.Model):
@@ -90,16 +78,13 @@ class UserSession(ormar.Model):
user_agent: str = ormar.String(max_length=255, nullable=True) user_agent: str = ormar.String(max_length=255, nullable=True)
last_seen: datetime = ormar.DateTime(default=datetime.now()) last_seen: datetime = ormar.DateTime(default=datetime.now())
class Meta: ormar_config = ormar.OrmarConfig(database=database, metadata=metadata, tablename="user_sessions")
tablename = "user_sessions"
metadata = metadata
database = database
class ABCDQuizAnswer(BaseModel): class ABCDQuizAnswer(BaseModel):
right: bool right: bool
answer: str answer: str
color: str | None color: str | None = None
class RangeQuizAnswer(BaseModel): class RangeQuizAnswer(BaseModel):
@@ -112,7 +97,7 @@ class RangeQuizAnswer(BaseModel):
class VotingQuizAnswer(BaseModel): class VotingQuizAnswer(BaseModel):
answer: str answer: str
image: str | None = None image: str | None = None
color: str | None color: str | None = None
class QuizQuestionType(str, Enum): class QuizQuestionType(str, Enum):
@@ -136,6 +121,7 @@ class QuizQuestion(BaseModel):
type: None | QuizQuestionType = QuizQuestionType.ABCD type: None | QuizQuestionType = QuizQuestionType.ABCD
answers: list[ABCDQuizAnswer] | RangeQuizAnswer | list[TextQuizAnswer] | list[VotingQuizAnswer] | str answers: list[ABCDQuizAnswer] | RangeQuizAnswer | list[TextQuizAnswer] | list[VotingQuizAnswer] | str
image: str | None = None image: str | None = None
hide_results: bool | None = False
@validator("answers") @validator("answers")
def answers_not_none_if_abcd_type(cls, v, values): def answers_not_none_if_abcd_type(cls, v, values):
@@ -160,10 +146,10 @@ class QuizInput(BaseModel):
public: bool = False public: bool = False
title: str title: str
description: str description: str
cover_image: str | None cover_image: str | None = None
background_color: str | None background_color: str | None = None
questions: list[QuizQuestion] questions: list[QuizQuestion]
background_image: str | None background_image: str | None = None
class Quiz(ormar.Model): class Quiz(ormar.Model):
@@ -186,19 +172,13 @@ class Quiz(ormar.Model):
views: 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) mod_rating: int | None = ormar.SmallInteger(nullable=True)
class Meta: ormar_config = ormar.OrmarConfig(tablename="quiz", metadata=metadata, database=database)
tablename = "quiz"
metadata = metadata
database = database
class InstanceData(ormar.Model): class InstanceData(ormar.Model):
instance_id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4(), nullable=False, unique=True) instance_id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4(), nullable=False, unique=True)
class Meta: ormar_config = ormar.OrmarConfig(tablename="instance_data", metadata=metadata, database=database)
tablename = "instance_data"
metadata = metadata
database = database
class Token(BaseModel): class Token(BaseModel):
@@ -228,18 +208,18 @@ class PlayGame(BaseModel):
game_pin: str game_pin: str
started: bool = False started: bool = False
captcha_enabled: bool = False captcha_enabled: bool = False
cover_image: str | None cover_image: str | None = None
game_mode: str | None game_mode: str | None = None
current_question: int = -1 current_question: int = -1
background_color: str | None background_color: str | None = None
background_image: str | None background_image: str | None = None
custom_field: str | None custom_field: str | None = None
question_show: bool = False question_show: bool = False
class GamePlayer(BaseModel): class GamePlayer(BaseModel):
username: str username: str
sid: str | None sid: str | None = None
class GameAnswer2(BaseModel): class GameAnswer2(BaseModel):
@@ -273,9 +253,9 @@ class AnswerData(BaseModel):
score: int score: int
class AnswerDataList(BaseModel): class AnswerDataList(RootModel):
# Just a method to make a top-level list # Just a method to make a top-level list
__root__: list[AnswerData] root: list[AnswerData]
class GameInLobby(BaseModel): class GameInLobby(BaseModel):
@@ -307,10 +287,7 @@ class GameResults(ormar.Model):
description: str = ormar.Text(nullable=False) description: str = ormar.Text(nullable=False)
questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False) questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False)
class Meta: ormar_config = ormar.OrmarConfig(database=database, metadata=metadata, tablename="game_results")
tablename = "game_results"
metadata = metadata
database = database
class QuizTivityInput(BaseModel): class QuizTivityInput(BaseModel):
@@ -325,10 +302,7 @@ class QuizTivity(ormar.Model):
user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
pages: list[QuizTivityPage] = ormar.JSON(nullable=False) pages: list[QuizTivityPage] = ormar.JSON(nullable=False)
class Meta: ormar_config = ormar.OrmarConfig(tablename="quiztivitys", metadata=metadata, database=database)
tablename = "quiztivitys"
metadata = metadata
database = database
class QuizTivityShare(ormar.Model): class QuizTivityShare(ormar.Model):
@@ -338,10 +312,7 @@ class QuizTivityShare(ormar.Model):
quiztivity: QuizTivity | None = ormar.ForeignKey(QuizTivity, ondelete=ReferentialAction.CASCADE) quiztivity: QuizTivity | None = ormar.ForeignKey(QuizTivity, ondelete=ReferentialAction.CASCADE)
user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
class Meta: ormar_config = ormar.OrmarConfig(database=database, metadata=metadata, tablename="quiztivityshares")
tablename = "quiztivityshares"
metadata = metadata
database = database
class OnlyId(BaseModel): class OnlyId(BaseModel):
@@ -350,8 +321,8 @@ class OnlyId(BaseModel):
class PublicQuizTivityShare(BaseModel): class PublicQuizTivityShare(BaseModel):
id: uuid.UUID id: uuid.UUID
name: str | None name: str | None = None
expire_in: int | None expire_in: int | None = None
quiztivity: OnlyId quiztivity: OnlyId
user: OnlyId user: OnlyId
@@ -386,23 +357,20 @@ class StorageItem(ormar.Model):
server: 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) imported: bool = ormar.Boolean(default=False, nullable=True)
class Meta: ormar_config = ormar.OrmarConfig(database=database, metadata=metadata, tablename="storage_items")
tablename = "storage_items"
metadata = metadata
database = database
class PublicStorageItem(BaseModel): class PublicStorageItem(BaseModel):
id: uuid.UUID id: uuid.UUID
uploaded_at: datetime uploaded_at: datetime
mime_type: str mime_type: str
hash: str | None hash: str | None = None
size: int size: int
deleted_at: datetime | None deleted_at: datetime | None = None
alt_text: str | None alt_text: str | None = None
filename: str | None filename: str | None = None
thumbhash: str | None thumbhash: str | None = None
server: str | None server: str | None = None
imported: bool imported: bool
@classmethod @classmethod
@@ -458,8 +426,8 @@ class PrivateStorageItem(PublicStorageItem):
class UpdateStorageItem(BaseModel): class UpdateStorageItem(BaseModel):
filename: str | None filename: str | None = None
alt_text: str | None alt_text: str | None = None
class Controller(ormar.Model): class Controller(ormar.Model):
@@ -473,10 +441,7 @@ class Controller(ormar.Model):
os_version: str | None = ormar.Text(nullable=True) os_version: str | None = ormar.Text(nullable=True)
wanted_os_version: str = ormar.Text(nullable=True, default=None) wanted_os_version: str = ormar.Text(nullable=True, default=None)
class Meta: ormar_config = ormar.OrmarConfig(tablename="controller", metadata=metadata, database=database)
tablename = "controller"
metadata = metadata
database = database
class Rating(ormar.Model): class Rating(ormar.Model):
@@ -486,7 +451,4 @@ class Rating(ormar.Model):
created_at: datetime = ormar.DateTime(nullable=False, server_default=func.now()) created_at: datetime = ormar.DateTime(nullable=False, server_default=func.now())
quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz) quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz)
class Meta: ormar_config = ormar.OrmarConfig(database=database, metadata=metadata, tablename="rating")
tablename = "rating"
metadata = metadata
database = database
+3 -3
View File
@@ -12,8 +12,8 @@ class Pdf(BaseModel):
class _MemoryCard(BaseModel): class _MemoryCard(BaseModel):
image: str | None image: str | None = None
text: str | None text: str | None = None
id: str id: str
@@ -53,6 +53,6 @@ TYPE_CLASS_LIST = {
class QuizTivityPage(BaseModel): class QuizTivityPage(BaseModel):
title: str | None title: str | None = None
type: QuizTivityTypes type: QuizTivityTypes
data: Pdf | Memory | Markdown | Abcd data: Pdf | Memory | Markdown | Abcd
+40 -40
View File
@@ -10,19 +10,19 @@ from pydantic import BaseModel
class _CoverMetadata(BaseModel): class _CoverMetadata(BaseModel):
id: UUID | None id: UUID | None = None
resources: str | None resources: str | None = None
class _CreatorAvatar(BaseModel): class _CreatorAvatar(BaseModel):
url: str | None url: str | None = None
id: UUID | None id: UUID | None = None
type: str | None type: str | None = None
bitmojiAvatarId: str | None bitmojiAvatarId: str | None = None
altText: str | None altText: str | None = None
contentType: str | None contentType: str | None = None
width: int | None width: int | None = None
height: int | None height: int | None = None
class _LastEdit(BaseModel): class _LastEdit(BaseModel):
@@ -32,19 +32,19 @@ class _LastEdit(BaseModel):
class _ImageMetadata(BaseModel): class _ImageMetadata(BaseModel):
id: UUID | None id: UUID | None = None
content_type: Optional[str] content_type: Optional[str] = None
width: Optional[int] width: Optional[int] = None
height: Optional[int] height: Optional[int] = None
resources: Optional[str] resources: Optional[str] = None
class _SampleQuestion(BaseModel): class _SampleQuestion(BaseModel):
image: str | None image: str | None = None
imageMetadata: _ImageMetadata | None imageMetadata: _ImageMetadata | None = None
title: str title: str
type: str type: str
time: int | None time: int | None = None
class _Access(BaseModel): class _Access(BaseModel):
@@ -57,19 +57,19 @@ class _Card(BaseModel):
title: str title: str
description: str description: str
slug: str slug: str
cover: str | None cover: str | None = None
coverMetadata: _CoverMetadata | dict[None, None] | None coverMetadata: _CoverMetadata | dict[None, None] | None = None
draftExists: bool draftExists: bool
inventoryItemIds: List[Any] inventoryItemIds: List[Any] = None
number_of_questions: int number_of_questions: int
creator: UUID creator: UUID
creator_username: str creator_username: str
creator_avatar: _CreatorAvatar | dict[None, None] | None creator_avatar: _CreatorAvatar | dict[None, None] | None = None
badges: List[str] badges: List[str]
visibility: int visibility: int
locked: bool locked: bool
writeProtection: bool writeProtection: bool
lastEdit: _LastEdit | None lastEdit: _LastEdit | None = None
featured: bool featured: bool
young_featured: bool young_featured: bool
sponsored: bool sponsored: bool
@@ -111,8 +111,8 @@ class _LanguageInfo(BaseModel):
class _Metadata(BaseModel): class _Metadata(BaseModel):
access: _Access access: _Access
duplicationProtection: bool | None duplicationProtection: bool | None = None
lastEdit: _LastEdit | None lastEdit: _LastEdit | None = None
class _Parent(BaseModel): class _Parent(BaseModel):
@@ -123,15 +123,15 @@ class _Parent(BaseModel):
class _Choice(BaseModel): class _Choice(BaseModel):
answer: str answer: str
correct: bool correct: bool
languageInfo: _LanguageInfo | None languageInfo: _LanguageInfo | None = None
class _Video(BaseModel): class _Video(BaseModel):
startTime: float startTime: float
endTime: float endTime: float
service: str service: str
full_url: Optional[str] full_url: Optional[str] = None
id: Optional[str] id: Optional[str] = None
class _Question(BaseModel): class _Question(BaseModel):
@@ -141,12 +141,12 @@ class _Question(BaseModel):
points: bool points: bool
pointsMultiplier: int pointsMultiplier: int
choices: List[_Choice] choices: List[_Choice]
image: str | None image: str | None = None
imageMetadata: _ImageMetadata | None imageMetadata: _ImageMetadata | None = None
resources: Optional[str] resources: Optional[str] = None
video: _Video video: _Video
questionFormat: int questionFormat: int
languageInfo: _LanguageInfo | None languageInfo: _LanguageInfo | None = None
media: List[Any] media: List[Any]
@@ -157,23 +157,23 @@ class _Kahoot(BaseModel):
creator_username: str creator_username: str
compatibilityLevel: int compatibilityLevel: int
creator_primary_usage: str creator_primary_usage: str
folderId: UUID | None folderId: UUID | None = None
visibility: int visibility: int
difficulty: int | None difficulty: int | None = None
audience: str audience: str
audience: str audience: str
title: str title: str
description: str description: str
quizType: str quizType: str
tags: str | None | List[str] tags: str | None | List[str] = None
cover: str | None cover: str | None = None
coverMetadata: _CoverMetadata | dict[None, None] | None coverMetadata: _CoverMetadata | dict[None, None] | None = None
questions: List[_Question] questions: List[_Question]
metadata: _Metadata metadata: _Metadata
parent: _Parent | None parent: _Parent | None = None
resources: str | None resources: str | None = None
slug: str slug: str
languageInfo: _LanguageInfo | None languageInfo: _LanguageInfo | None = None
inventoryItemIds: List[Any] inventoryItemIds: List[Any]
type: str type: str
created: int created: int
+2 -2
View File
@@ -15,12 +15,12 @@ from classquiz.kahoot_importer import _Entity
class _Response(BaseModel): class _Response(BaseModel):
entities: List[_Entity] entities: List[_Entity]
totalHits: int totalHits: int
cursor: int | None cursor: int | None = None
pageTimestamp: int pageTimestamp: int
async def search( async def search(
query: str | None, query: str | None = None,
limit: int | None = 9, limit: int | None = 9,
cursor: int | None = 1, cursor: int | None = 1,
search_cluster: int | None = 1, search_cluster: int | None = 1,
+1 -1
View File
@@ -18,7 +18,7 @@ router = APIRouter()
class SetControllerUpInput(BaseModel): class SetControllerUpInput(BaseModel):
player_name: str | None player_name: str | None = None
name: str name: str
+2 -2
View File
@@ -50,7 +50,7 @@ class LoginSession(BaseModel):
user_id: str user_id: str
step_1: set[StartLoginResponseTypes] step_1: set[StartLoginResponseTypes]
step_2: set[StartLoginResponseTypes] step_2: set[StartLoginResponseTypes]
webauthn_challenge: str | None webauthn_challenge: str | None = None
step1_success: bool = False step1_success: bool = False
@@ -58,7 +58,7 @@ class StartLoginResponse(BaseModel):
step_1: set[StartLoginResponseTypes] step_1: set[StartLoginResponseTypes]
step_2: set[StartLoginResponseTypes] step_2: set[StartLoginResponseTypes]
session_id: str session_id: str
webauthn_data: None | str webauthn_data: None | str = None
def verify_webauthn(data, fidocredentialss: list[FidoCredentials], login_session: LoginSession): def verify_webauthn(data, fidocredentialss: list[FidoCredentials], login_session: LoginSession):
+1 -1
View File
@@ -47,7 +47,7 @@ async def get_newest_quizzes(
class SetModRatingForQuizInput(BaseModel): class SetModRatingForQuizInput(BaseModel):
rating: int | None rating: int | None = None
@router.post("/rating/set/{quiz_id}") @router.post("/rating/set/{quiz_id}")
+6 -4
View File
@@ -22,6 +22,7 @@ from classquiz.config import redis, settings, storage, meilisearch
from classquiz.db.models import Quiz, User, PlayGame, GameInLobby, QuizQuestion, QuizQuestionType from classquiz.db.models import Quiz, User, PlayGame, GameInLobby, QuizQuestion, QuizQuestionType
from classquiz.helpers.box_controller import generate_code from classquiz.helpers.box_controller import generate_code
from classquiz.kahoot_importer.import_quiz import import_quiz from classquiz.kahoot_importer.import_quiz import import_quiz
from uuid import UUID
import urllib.parse import urllib.parse
settings = settings() settings = settings()
@@ -54,7 +55,7 @@ class PublicQuizResponseUser(BaseModel):
id: uuid.UUID id: uuid.UUID
class PublicQuizResponse(Quiz.get_pydantic()): class PublicQuizResponse(Quiz.get_pydantic(exclude={"questions"})):
user_id: PublicQuizResponseUser user_id: PublicQuizResponseUser
questions: list[QuizQuestion] questions: list[QuizQuestion]
likes: int likes: int
@@ -143,8 +144,8 @@ async def start_quiz(
class CheckIfCaptchaEnabledResponse(BaseModel): class CheckIfCaptchaEnabledResponse(BaseModel):
enabled: bool enabled: bool
game_mode: str | None game_mode: str | None = None
custom_field: str | None custom_field: str | None = None
@router.get("/play/check_captcha/{game_pin}", response_model=CheckIfCaptchaEnabledResponse) @router.get("/play/check_captcha/{game_pin}", response_model=CheckIfCaptchaEnabledResponse)
@@ -229,7 +230,8 @@ async def export_quiz_answers(export_token: str, game_pin: str):
data = json.loads(data) data = json.loads(data)
data2 = await redis.get(f"game:{game_pin}") data2 = await redis.get(f"game:{game_pin}")
game_data = PlayGame.parse_raw(data2) game_data = PlayGame.parse_raw(data2)
quiz = await Quiz.objects.get_or_none(id=game_data.quiz_id) print(type(game_data.quiz_id))
quiz = await Quiz.objects.get_or_none(id=UUID(game_data.quiz_id))
if quiz is None: if quiz is None:
raise HTTPException(status_code=404, detail="quiz not found") raise HTTPException(status_code=404, detail="quiz not found")
+4 -4
View File
@@ -25,9 +25,9 @@ async def get_shares(user: User = Depends(get_current_user)) -> list[PublicQuizT
class CreateShareInput(BaseModel): class CreateShareInput(BaseModel):
name: str | None name: str | None = None
quiztivity: UUID quiztivity: UUID
expire_in: int | None expire_in: int | None = None
@router.post("/") @router.post("/")
@@ -55,8 +55,8 @@ async def delete_share(uuid: UUID, user: User = Depends(get_current_user)):
class UpdateShareInput(BaseModel): class UpdateShareInput(BaseModel):
name: str | None name: str | None = None
expire_in: int | None expire_in: int | None = None
@router.put("/{uuid}") @router.put("/{uuid}")
+1 -1
View File
@@ -45,7 +45,7 @@ class SitemapQuiz(BaseModel):
updated_at: datetime.datetime updated_at: datetime.datetime
class Config: class Config:
orm_mode = True from_attributes = True
@router.get("/get") @router.get("/get")
+1 -1
View File
@@ -297,7 +297,7 @@ async def get_other_avatar(respo: Response, user_id: uuid.UUID):
class InternalAuthData(BaseModel): class InternalAuthData(BaseModel):
rememberme: str rememberme: str
jwt: str | None jwt: str | None = None
@router.post("/auth/internal") @router.post("/auth/internal")
+89 -42
View File
@@ -28,6 +28,7 @@ from pydantic import BaseModel, ValidationError, validator
from datetime import datetime from datetime import datetime
from classquiz.socket_server.export_helpers import save_quiz_to_storage from classquiz.socket_server.export_helpers import save_quiz_to_storage
from classquiz.socket_server.session import get_session, save_session
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins=[]) sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins=[])
settings = settings() settings = settings()
@@ -61,10 +62,10 @@ def calculate_score(z: float, t: int) -> int:
async def set_answer(answers, game_pin: str, q_index: int, data: AnswerData) -> AnswerDataList: async def set_answer(answers, game_pin: str, q_index: int, data: AnswerData) -> AnswerDataList:
if answers is None: if answers is None:
answers = AnswerDataList(__root__=[data]) answers = AnswerDataList([data])
else: else:
answers = AnswerDataList.parse_raw(answers) answers = AnswerDataList.parse_raw(answers)
answers.__root__.append(data) answers.root.append(data)
await redis.set( await redis.set(
f"game_session:{game_pin}:{q_index}", f"game_session:{game_pin}:{q_index}",
answers.json(), answers.json(),
@@ -76,8 +77,8 @@ async def set_answer(answers, game_pin: str, q_index: int, data: AnswerData) ->
class _JoinGameData(BaseModel): class _JoinGameData(BaseModel):
username: str username: str
game_pin: str game_pin: str
captcha: str | None captcha: str | None = None
custom_field: str | None custom_field: str | None = None
class _RejoinGameData(BaseModel): class _RejoinGameData(BaseModel):
@@ -105,9 +106,13 @@ async def rejoin_game(sid: str, data: dict):
await sio.emit("time_sync", encrypted_datetime, room=sid) await sio.emit("time_sync", encrypted_datetime, room=sid)
await redis.set(redis_sid_key, sid) await redis.set(redis_sid_key, sid)
await redis.srem( await redis.srem(
f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=data.old_sid).json() f"game_session:{data.game_pin}:players",
GamePlayer(username=data.username, sid=data.old_sid).json(),
)
await redis.sadd(
f"game_session:{data.game_pin}:players",
GamePlayer(username=data.username, sid=sid).json(),
) )
await redis.sadd(f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).json())
game_data = PlayGame.parse_raw(redis_res) game_data = PlayGame.parse_raw(redis_res)
session = { session = {
"game_pin": data.game_pin, "game_pin": data.game_pin,
@@ -115,7 +120,7 @@ async def rejoin_game(sid: str, data: dict):
"sid_custom": sid, "sid_custom": sid,
"admin": False, "admin": False,
} }
await sio.save_session(sid, session) await save_session(sid, sio, session)
await sio.enter_room(sid, data.game_pin) await sio.enter_room(sid, data.game_pin)
await sio.emit( await sio.emit(
"rejoined_game", "rejoined_game",
@@ -151,7 +156,10 @@ async def join_game(sid: str, data: dict):
try: try:
async with session.post( async with session.post(
"https://hcaptcha.com/siteverify", "https://hcaptcha.com/siteverify",
data={"response": data.captcha, "secret": settings.hcaptcha_key}, data={
"response": data.captcha,
"secret": settings.hcaptcha_key,
},
) as resp: ) as resp:
resp_data = await resp.json() resp_data = await resp.json()
if not resp_data["success"]: if not resp_data["success"]:
@@ -163,7 +171,10 @@ async def join_game(sid: str, data: dict):
elif settings.recaptcha_key is not None: elif settings.recaptcha_key is not None:
async with session.post( async with session.post(
"https://www.google.com/recaptcha/api/siteverify", "https://www.google.com/recaptcha/api/siteverify",
data={"secret": settings.recaptcha_key, "response": data.captcha}, data={
"secret": settings.recaptcha_key,
"response": data.captcha,
},
) as resp: ) as resp:
try: try:
resp_data = await resp.json() resp_data = await resp.json()
@@ -186,7 +197,7 @@ async def join_game(sid: str, data: dict):
"sid_custom": sid, "sid_custom": sid,
"admin": False, "admin": False,
} }
await sio.save_session(sid, session) await save_session(sid, sio, session)
await sio.emit( await sio.emit(
"joined_game", "joined_game",
{ {
@@ -198,11 +209,18 @@ async def join_game(sid: str, data: dict):
redis_res = await redis.get(f"game_session:{data.game_pin}") redis_res = await redis.get(f"game_session:{data.game_pin}")
redis_res = GameSession.parse_raw(redis_res) redis_res = GameSession.parse_raw(redis_res)
await redis.set(f"game_session:{data.game_pin}:players:{data.username}", sid, ex=7200) await redis.set(f"game_session:{data.game_pin}:players:{data.username}", sid, ex=7200)
await redis.sadd(f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).json()) await redis.sadd(
f"game_session:{data.game_pin}:players",
GamePlayer(username=data.username, sid=sid).json(),
)
if data.custom_field == "": if data.custom_field == "":
data.custom_field = None data.custom_field = None
if data.custom_field is not None: if data.custom_field is not None:
await redis.hset(f"game:{data.game_pin}:players:custom_fields", data.username, data.custom_field) await redis.hset(
f"game:{data.game_pin}:players:custom_fields",
data.username,
data.custom_field,
)
# await redis.set( # await redis.set(
# f"game_session:{data.game_pin}", # f"game_session:{data.game_pin}",
@@ -223,7 +241,7 @@ async def join_game(sid: str, data: dict):
@sio.event @sio.event
async def start_game(sid: str, _data: dict): async def start_game(sid: str, _data: dict):
session = await sio.get_session(sid) session = await get_session(sid, sio)
if not session["admin"]: if not session["admin"]:
return return
game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}")) game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}"))
@@ -260,10 +278,11 @@ async def register_as_admin(sid: str, data: dict):
{"game_id": game_id, "game": await redis.get(f"game:{game_pin}")}, {"game_id": game_id, "game": await redis.get(f"game:{game_pin}")},
room=sid, room=sid,
) )
async with sio.session(sid) as session: session = {}
session["game_pin"] = game_pin session["game_pin"] = game_pin
session["admin"] = True session["admin"] = True
session["remote"] = False session["remote"] = False
await save_session(sid, sio, session)
await sio.enter_room(sid, game_pin) await sio.enter_room(sid, game_pin)
await sio.enter_room(sid, f"admin:{data.game_pin}") await sio.enter_room(sid, f"admin:{data.game_pin}")
else: else:
@@ -272,7 +291,7 @@ async def register_as_admin(sid: str, data: dict):
@sio.event @sio.event
async def get_question_results(sid: str, data: dict): async def get_question_results(sid: str, data: dict):
session = await sio.get_session(sid) session = await get_session(sid, sio)
if not session["admin"]: if not session["admin"]:
return return
@@ -280,7 +299,7 @@ async def get_question_results(sid: str, data: dict):
if redis_res is None: if redis_res is None:
redis_res = [] redis_res = []
else: else:
redis_res = AnswerDataList.parse_raw(redis_res).dict()["__root__"] redis_res = AnswerDataList.parse_raw(redis_res).model_dump()
game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}")) game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}"))
game_data.question_show = False game_data.question_show = False
await redis.set(f"game:{session['game_pin']}", game_data.json()) await redis.set(f"game:{session['game_pin']}", game_data.json())
@@ -291,7 +310,7 @@ async def get_question_results(sid: str, data: dict):
class ABCDQuizAnswerWithoutSolution(BaseModel): class ABCDQuizAnswerWithoutSolution(BaseModel):
answer: str answer: str
color: str | None color: str | None = None
class RangeQuizAnswerWithoutSolution(BaseModel): class RangeQuizAnswerWithoutSolution(BaseModel):
@@ -318,7 +337,7 @@ class ReturnQuestion(QuizQuestion):
@sio.event @sio.event
async def set_question_number(sid, data: str): async def set_question_number(sid, data: str):
# data is just a number (as a str) of the question # data is just a number (as a str) of the question
session = await sio.get_session(sid) session = await get_session(sid, sio)
if not session["admin"]: if not session["admin"]:
return return
game_pin = session["game_pin"] game_pin = session["game_pin"]
@@ -360,7 +379,7 @@ class _SubmitAnswerDataOrderType(BaseModel):
class _SubmitAnswerData(BaseModel): class _SubmitAnswerData(BaseModel):
question_index: int question_index: int
answer: str answer: str
complex_answer: list[_SubmitAnswerDataOrderType] | None complex_answer: list[_SubmitAnswerDataOrderType] | None = None
@sio.event @sio.event
@@ -372,7 +391,7 @@ async def submit_answer(sid: str, data: dict):
await sio.emit("error", room=sid) await sio.emit("error", room=sid)
print(e) print(e)
return return
session = await sio.get_session(sid) session = await get_session(sid, sio)
game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}")) game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}"))
answer_right = False answer_right = False
if game_data.questions[int(float(data.question_index))].type == QuizQuestionType.ABCD: if game_data.questions[int(float(data.question_index))].type == QuizQuestionType.ABCD:
@@ -423,14 +442,15 @@ async def submit_answer(sid: str, data: dict):
answer_right = bool(correct_string == data.answer) answer_right = bool(correct_string == data.answer)
else: else:
raise NotImplementedError raise NotImplementedError
latency = int(float((await sio.get_session(sid))["ping"])) latency = int(float((await get_session(sid, sio))["ping"]))
time_q_started = datetime.fromisoformat(await redis.get(f"game:{session['game_pin']}:current_time")) time_q_started = datetime.fromisoformat(await redis.get(f"game:{session['game_pin']}:current_time"))
diff = (time_q_started - now).total_seconds() * 1000 # - timedelta(milliseconds=latency) diff = (time_q_started - now).total_seconds() * 1000 # - timedelta(milliseconds=latency)
score = 0 score = 0
if answer_right: if answer_right:
score = calculate_score( score = calculate_score(
abs(diff) - latency, int(float(game_data.questions[int(float(data.question_index))].time)) abs(diff) - latency,
int(float(game_data.questions[int(float(data.question_index))].time)),
) )
if score > 1000: if score > 1000:
score = 1000 score = 1000
@@ -444,11 +464,14 @@ async def submit_answer(sid: str, data: dict):
) )
answers = await redis.get(f"game_session:{session['game_pin']}:{data.question_index}") answers = await redis.get(f"game_session:{session['game_pin']}:{data.question_index}")
answers = await set_answer( answers = await set_answer(
answers, game_pin=session["game_pin"], data=answer_data, q_index=int(float(data.question_index)) answers,
game_pin=session["game_pin"],
data=answer_data,
q_index=int(float(data.question_index)),
) )
player_count = await redis.scard(f"game_session:{session['game_pin']}:players") player_count = await redis.scard(f"game_session:{session['game_pin']}:players")
await sio.emit("player_answer", {}) await sio.emit("player_answer", {})
if len(answers.__root__) == player_count: if len(answers.root) == player_count:
# await sio.emit( # await sio.emit(
# "question_results", # "question_results",
# await redis.get(f"game_session:{session['game_pin']}:{data.question_index}"), # await redis.get(f"game_session:{session['game_pin']}:{data.question_index}"),
@@ -465,7 +488,7 @@ async def submit_answer(sid: str, data: dict):
@sio.event @sio.event
async def get_final_results(sid: str, _data: dict): async def get_final_results(sid: str, _data: dict):
session: dict = await sio.get_session(sid) session: dict = await get_session(sid, sio)
game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}"))) game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}")))
if not session["admin"]: if not session["admin"]:
return return
@@ -475,7 +498,7 @@ async def get_final_results(sid: str, _data: dict):
@sio.event @sio.event
async def get_export_token(sid: str): async def get_export_token(sid: str):
session = await sio.get_session(sid) session = await get_session(sid, sio)
if not session["admin"]: if not session["admin"]:
return return
game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}"))) game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}")))
@@ -487,11 +510,15 @@ async def get_export_token(sid: str):
@sio.event @sio.event
async def show_solutions(sid: str, _data: dict): async def show_solutions(sid: str, _data: dict):
session: dict = await sio.get_session(sid) session: dict = await get_session(sid, sio)
game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}"))) game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}")))
if not session["admin"]: if not session["admin"]:
return return
await sio.emit("solutions", game_data.questions[game_data.current_question].dict(), room=session["game_pin"]) await sio.emit(
"solutions",
game_data.questions[game_data.current_question].dict(),
room=session["game_pin"],
)
@sio.event @sio.event
@@ -500,8 +527,9 @@ async def echo_time_sync(sid: str, data: str):
then = datetime.fromisoformat(then_dec) then = datetime.fromisoformat(then_dec)
now = datetime.now() now = datetime.now()
delta = now - then delta = now - then
async with sio.session(sid) as session: session = await get_session(sid, sio)
session["ping"] = delta.microseconds / 1000 session["ping"] = delta.microseconds / 1000
await save_session(sid, sio, session)
class _KickPlayerInput(BaseModel): class _KickPlayerInput(BaseModel):
@@ -517,13 +545,14 @@ async def kick_player(sid: str, data: dict):
print(e) print(e)
return return
session: dict = await sio.get_session(sid) session: dict = await get_session(sid, sio)
if not session["admin"]: if not session["admin"]:
return return
player_sid = await redis.get(f"game_session:{session['game_pin']}:players:{data.username}") player_sid = await redis.get(f"game_session:{session['game_pin']}:players:{data.username}")
await redis.srem( await redis.srem(
f"game_session:{session['game_pin']}:players", GamePlayer(username=data.username, sid=player_sid).json() f"game_session:{session['game_pin']}:players",
GamePlayer(username=data.username, sid=player_sid).json(),
) )
await sio.leave_room(player_sid, session["game_pin"]) await sio.leave_room(player_sid, session["game_pin"])
await sio.emit("kick", room=player_sid) await sio.emit("kick", room=player_sid)
@@ -548,10 +577,11 @@ async def register_as_remote(sid: str, data: dict):
room=sid, room=sid,
) )
await sio.emit("control_visibility", {"visible": False}, room=f"admin:{data.game_pin}") await sio.emit("control_visibility", {"visible": False}, room=f"admin:{data.game_pin}")
async with sio.session(sid) as session: session = await get_session(sid, sio)
session["game_pin"] = data.game_pin session["game_pin"] = data.game_pin
session["admin"] = True session["admin"] = True
session["remote"] = True session["remote"] = True
await save_session(sid, sio, session)
await sio.enter_room(sid, data.game_pin) await sio.enter_room(sid, data.game_pin)
await sio.enter_room(sid, f"admin:{data.game_pin}") await sio.enter_room(sid, f"admin:{data.game_pin}")
@@ -568,14 +598,31 @@ async def set_control_visibility(sid: str, data: dict):
await sio.emit("error", room=sid) await sio.emit("error", room=sid)
print(e) print(e)
return return
session: dict = await sio.get_session(sid) session: dict = await get_session(sid, sio)
await sio.emit("control_visibility", {"visible": data.visible}, room=f"admin:{session['game_pin']}") await sio.emit(
"control_visibility",
{"visible": data.visible},
room=f"admin:{session['game_pin']}",
)
@sio.event @sio.event
async def save_quiz(sid: str): async def save_quiz(sid: str):
session: dict = await sio.get_session(sid) session: dict = await get_session(sid, sio)
if not session["admin"]: if not session["admin"]:
return return
await save_quiz_to_storage(session["game_pin"]) await save_quiz_to_storage(session["game_pin"])
await sio.emit("results_saved_successfully") await sio.emit("results_saved_successfully")
class ConnectSessionIdEvent(BaseModel):
session_id: str
@sio.event
async def connect(sid: str, _environ, _auth):
session_id = os.urandom(16).hex()
print("Connection opened with handler")
sio_session = {"session_id": session_id}
await sio.save_session(sid, sio_session)
await sio.emit("session_id", ConnectSessionIdEvent(session_id=session_id).dict())
+26
View File
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
#
# SPDX-License-Identifier: MPL-2.0
from classquiz.config import redis
import json
from typing import Any
from socketio import AsyncServer
from socketio.exceptions import ConnectionRefusedError
async def get_session(sid: str, sio: AsyncServer, disconnect_on_error: bool = True) -> dict:
session_id = (await sio.get_session(sid)).get("session_id")
if session_id is None:
raise ConnectionRefusedError("Session not configured")
val = await redis.get(f"socket_io_session:{session_id}")
if disconnect_on_error and val is None:
raise ConnectionRefusedError("session not available")
return json.loads(val)
async def save_session(sid: str, sio: AsyncServer, data: Any, disconnect_on_error: bool = True) -> None:
session_id = (await sio.get_session(sid)).get("session_id")
if session_id is None:
raise ConnectionRefusedError("Session not configured")
await redis.set(f"socket_io_session:{session_id}", json.dumps(data), ex=3600)
+1 -1
View File
@@ -27,4 +27,4 @@ class WorkerSettings:
cron_jobs = [cron(clean_editor_images_up, hour={0, 6, 12, 18}, minute=0)] cron_jobs = [cron(clean_editor_images_up, hour={0, 6, 12, 18}, minute=0)]
on_startup = startup on_startup = startup
on_shutdown = shutdown on_shutdown = shutdown
redis_settings = RedisSettings.from_dsn(settings.redis) redis_settings = RedisSettings.from_dsn(str(settings.redis))
+4
View File
@@ -44,6 +44,10 @@ services:
- 9000:9000 - 9000:9000
- 9001:9001 - 9001:9001
restart: unless-stopped restart: unless-stopped
redis:
image: redis
ports:
- 6379:6379
volumes: volumes:
db: db:
search: search:
+4 -4
View File
@@ -29,13 +29,13 @@ services:
# --- DON'T CHANGE FROM HERE --- # --- DON'T CHANGE FROM HERE ---
DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz" # DON'T CHANGE DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz" # DON'T CHANGE
REDIS: "redis://redis:6379/0?decode_responses=True" # DON'T CHANGE REDIS: "redis://redis:6379/0?decode_responses=True" # DON'T CHANGE
SECRET_KEY: "TOP_SECRET" # Don't change it manually, use the one-liner provided in the documentation SECRET_KEY: "f2a08b44a104b4b4836fe4da9b25b795e1669f08bd1578397ecb19d176952780" # Don't change it manually, use the one-liner provided in the documentation
MAX_WORKERS: "1" # Very important and DON'T CHANGE MAX_WORKERS: "1" # Very important and DON'T CHANGE
ACCESS_TOKEN_EXPIRE_MINUTES: 30 # DON'T CHANGE ACCESS_TOKEN_EXPIRE_MINUTES: 30 # DON'T CHANGE
MEILISEARCH_URL: "http://meilisearch:7700" # DON'T CHANGE MEILISEARCH_URL: "http://meilisearch:7700" # DON'T CHANGE
# -- DON'T CHANGE TILL HERE --- # -- DON'T CHANGE TILL HERE ---
# --- GENERAL CONFI --- # --- GENERAL CONFIG ---
ROOT_ADDRESS: "https://classquiz.de" # CHANGE IT (without a "/" at the end) ROOT_ADDRESS: "https://classquiz.de" # CHANGE IT (without a "/" at the end)
# --- MAIL CONFIG --- # --- MAIL CONFIG ---
@@ -43,11 +43,11 @@ services:
MAIL_ADDRESS: "email@email@email.email" MAIL_ADDRESS: "email@email@email.email"
MAIL_PASSWORD: "PASSWORT" MAIL_PASSWORD: "PASSWORT"
MAIL_USERNAME: "email@email@email.email" MAIL_USERNAME: "email@email@email.email"
MAIL_SERVER: "email@email@email.emai" MAIL_SERVER: "smtp.email.email"
SKIP_EMAIL_VERIFICATION: "True" # Does the user have to confirm its email by clicking a link? SKIP_EMAIL_VERIFICATION: "True" # Does the user have to confirm its email by clicking a link?
# --- EXTERNAL API CONFIG --- # --- EXTERNAL API CONFIG ---
# HCAPTCHA_KEY: "HCAPTCHA_PRIVATE_KEY" # HCAPTCHA_KEY: ""
# PIXABAY_API_KEY: "" # Get it from here: https://pixabay.com/api/docs/ # PIXABAY_API_KEY: "" # Get it from here: https://pixabay.com/api/docs/
# RECAPTCHA_KEY: "" Get it from Google for the Captcha. # RECAPTCHA_KEY: "" Get it from Google for the Captcha.
+2 -1
View File
@@ -12,10 +12,11 @@ ENV REDIS_URL=redis://localhost:6379
ENV VITE_MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoibWF3b2thIiwiYSI6ImNsMjBob3d4ZjBhcGszYnE0bWp4aXB1ZW4ifQ.IByxV1qeIuEWpHCWsuB88A ENV VITE_MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoibWF3b2thIiwiYSI6ImNsMjBob3d4ZjBhcGszYnE0bWp4aXB1ZW4ifQ.IByxV1qeIuEWpHCWsuB88A
# This Mapbox-token is restricted to the following urls: classquiz.de, classquiz.mawoka.eu, test.com # This Mapbox-token is restricted to the following urls: classquiz.de, classquiz.mawoka.eu, test.com
ENV VITE_HCAPTCHA=ee81b2a1-acf3-4d20-b2a4-a7ea94c7eba5 ENV VITE_HCAPTCHA=ee81b2a1-acf3-4d20-b2a4-a7ea94c7eba5
# Hacaptcha secret ES_e0f8139cb5ad467d892e8d73a020cd2b
# ENV VITE_SENTRY=https://75cb4ef1be624d8f81bbaf864b722f8a@glitch.mawoka.eu/2 # ENV VITE_SENTRY=https://75cb4ef1be624d8f81bbaf864b722f8a@glitch.mawoka.eu/2
#ENV VITE_GOOGLE_AUTH_ENABLED=true #ENV VITE_GOOGLE_AUTH_ENABLED=true
#ENV VITE_GITHUB_AUTH_ENABLED=true #ENV VITE_GITHUB_AUTH_ENABLED=true
#ENV VITE_CAPTCHA_ENABLED=true ENV VITE_CAPTCHA_ENABLED=false
#ENV VITE_REGISTRATION_DISABLED=True #ENV VITE_REGISTRATION_DISABLED=True
#ENV VITE_PLAUSIBLE_DATA_URL= #ENV VITE_PLAUSIBLE_DATA_URL=
# change working directory # change working directory
+6 -6
View File
@@ -16,12 +16,12 @@
}, },
"devDependencies": { "devDependencies": {
"@beyonk/svelte-mapbox": "^9.0.5", "@beyonk/svelte-mapbox": "^9.0.5",
"@ckeditor/ckeditor5-autoformat": "^37.1.0", "@ckeditor/ckeditor5-autoformat": "^41.1.0",
"@ckeditor/ckeditor5-basic-styles": "^37.1.0", "@ckeditor/ckeditor5-basic-styles": "^41.1.0",
"@ckeditor/ckeditor5-build-balloon": "^37.1.0", "@ckeditor/ckeditor5-build-balloon": "^41.1.0",
"@ckeditor/ckeditor5-editor-balloon": "^37.1.0", "@ckeditor/ckeditor5-editor-balloon": "^41.1.0",
"@ckeditor/ckeditor5-essentials": "^37.1.0", "@ckeditor/ckeditor5-essentials": "^41.1.0",
"@ckeditor/ckeditor5-theme-lark": "^37.1.0", "@ckeditor/ckeditor5-theme-lark": "^41.1.0",
"@felte/reporter-tippy": "^1.1.5", "@felte/reporter-tippy": "^1.1.5",
"@felte/validator-yup": "^1.0.11", "@felte/validator-yup": "^1.0.11",
"@ffmpeg/core": "^0.11.0", "@ffmpeg/core": "^0.11.0",
+6415 -4744
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -130,7 +130,7 @@ SPDX-License-Identifier: MPL-2.0
{/if} {/if}
{/if} {/if}
<br /> <br />
{#if timer_res === '0' && JSON.stringify(final_results) === JSON.stringify( [null] ) && quiz_data.questions[selected_question].type !== QuizQuestionType.SLIDE && question_results !== null} {#if timer_res === '0' && JSON.stringify(final_results) === JSON.stringify( [null] ) && quiz_data.questions[selected_question].type !== QuizQuestionType.SLIDE && question_results !== null && quiz_data.questions[selected_question]?.hide_results !== true}
{#if question_results === undefined} {#if question_results === undefined}
{#if !final_results_clicked} {#if !final_results_clicked}
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
@@ -4,6 +4,10 @@ SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
SPDX-License-Identifier: MPL-2.0 SPDX-License-Identifier: MPL-2.0
--> -->
<!--
This should be okay, right?
-->
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { tinykeys } from '$lib/tinykeys'; import { tinykeys } from '$lib/tinykeys';
@@ -194,7 +198,6 @@ SPDX-License-Identifier: MPL-2.0
}; };
const on_enter = (e: KeyboardEvent) => { const on_enter = (e: KeyboardEvent) => {
e.preventDefault();
if (selected === null) { if (selected === null) {
return; return;
} }
@@ -231,6 +234,7 @@ SPDX-License-Identifier: MPL-2.0
<div <div
class="fixed top-0 left-0 w-screen h-screen flex bg-black bg-opacity-50 z-50" class="fixed top-0 left-0 w-screen h-screen flex bg-black bg-opacity-50 z-50"
on:click={close_on_outside} on:click={close_on_outside}
on:keyup={close_on_outside}
transition:fade={{ duration: 60 }} transition:fade={{ duration: 60 }}
> >
<div class="m-auto w-1/3 h-2/3 rounded bg-black flex flex-col"> <div class="m-auto w-1/3 h-2/3 rounded bg-black flex flex-col">
@@ -242,7 +246,6 @@ SPDX-License-Identifier: MPL-2.0
</p> </p>
<input <input
type="text" type="text"
autofocus
class="col-start-1 row-start-1 bg-transparent w-full p-4 outline-none bg-gray-700 rounded" class="col-start-1 row-start-1 bg-transparent w-full p-4 outline-none bg-gray-700 rounded"
bind:value={input} bind:value={input}
/> />
+56 -6
View File
@@ -11,23 +11,27 @@ SPDX-License-Identifier: MPL-2.0
import { reach } from 'yup'; import { reach } from 'yup';
import { dataSchema } from '$lib/yupSchemas'; import { dataSchema } from '$lib/yupSchemas';
import Spinner from '../Spinner.svelte'; import Spinner from '../Spinner.svelte';
// import { createTippy } from 'svelte-tippy'; import { createTippy } from 'svelte-tippy';
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import MediaComponent from '$lib/editor/MediaComponent.svelte'; import MediaComponent from '$lib/editor/MediaComponent.svelte';
import { fade } from 'svelte/transition';
import BrownButton from '$lib/components/buttons/brown.svelte';
// import MediaComponent from "$lib/editor/MediaComponent.svelte"; // import MediaComponent from "$lib/editor/MediaComponent.svelte";
const { t } = getLocalization(); const { t } = getLocalization();
/* const tippy = createTippy({ const tippy = createTippy({
arrow: true, arrow: true,
animation: 'perspective-subtle', animation: 'perspective-subtle',
placement: 'top' placement: 'top'
});*/ });
export let data: EditorData; export let data: EditorData;
export let selected_question: number; export let selected_question: number;
export let edit_id: string; export let edit_id: string;
let advanced_options_open = false;
let uppyOpen = false; let uppyOpen = false;
let unique = {}; let unique = {};
@@ -93,6 +97,33 @@ SPDX-License-Identifier: MPL-2.0
<span <span
class="inline-block bg-gray-600 w-4 h-4 rounded-full hover:bg-green-400 transition" class="inline-block bg-gray-600 w-4 h-4 rounded-full hover:bg-green-400 transition"
/> />
<button
class="ml-auto"
type="button"
use:tippy={{ content: $t('editor.advanced_settings') }}
on:click={() => (advanced_options_open = true)}
>
<svg
class="text-white w-5 h-5"
aria-hidden="true"
fill="none"
stroke="white"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<path
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</button>
</div> </div>
</div> </div>
{#if data.questions[selected_question].type === QuizQuestionType.SLIDE} {#if data.questions[selected_question].type === QuizQuestionType.SLIDE}
@@ -233,3 +264,22 @@ SPDX-License-Identifier: MPL-2.0
{/if} {/if}
</div> </div>
</div> </div>
{#if advanced_options_open}
<div
class="fixed top-0 left-0 w-full h-full bg-black/60 flex"
transition:fade={{ duration: 150 }}
>
<div class="w-1/4 h-1/3 m-auto bg-white dark:bg-gray-700 rounded-lg flex flex-col p-2 gap-2">
<h1 class="text-3xl mx-auto">{$t('editor.advanced_settings')}</h1>
<label class="flex justify-around text-lg">
<span class="my-auto">{$t('editor.hide_question_results')}</span>
<input type="checkbox" bind:checked={data.questions[selected_question]["hide_results"]} />
</label>
<div class="mt-auto w-full">
<BrownButton on:click={() => (advanced_options_open = false)}>{$t('words.close')}</BrownButton>
</div>
</div>
</div>
{/if}
+3 -1
View File
@@ -231,7 +231,9 @@
"enter_answer": "Gib eine Antwort ein", "enter_answer": "Gib eine Antwort ein",
"visit_docs": "Besuche die Dokumentation.", "visit_docs": "Besuche die Dokumentation.",
"enable_reorder": "Fragen Umsortieren", "enable_reorder": "Fragen Umsortieren",
"disable_reorder": "Umsortieren beenden" "disable_reorder": "Umsortieren beenden",
"advanced_settings": "Erweiterte Einstellungen",
"hide_question_results": "Frageergebnisse ausblenden?"
}, },
"import": { "import": {
"need_help": "", "need_help": "",
+3 -1
View File
@@ -226,7 +226,9 @@
"visit_docs": "Visit the docs.", "visit_docs": "Visit the docs.",
"enter_answer": "Enter an answer", "enter_answer": "Enter an answer",
"enable_reorder": "Enable reorder mode", "enable_reorder": "Enable reorder mode",
"disable_reorder": "Disable reorder mode" "disable_reorder": "Disable reorder mode",
"advanced_settings": "Advanced Settings",
"hide_question_results": "Hide question resuluts?"
}, },
"import_page": { "import_page": {
"need_help": "Need help?", "need_help": "Need help?",
@@ -79,6 +79,17 @@ SPDX-License-Identifier: MPL-2.0
class="admin-button" class="admin-button"
>{$t('admin_page.next_question', { question: selected_question + 2 })} >{$t('admin_page.next_question', { question: selected_question + 2 })}
</button> </button>
{:else if quiz_data.questions[selected_question]?.hide_results === true}
<button
on:click={() => {
get_question_results();
setTimeout(() => {
set_question_number(selected_question + 1);
}, 200);
}}
class="admin-button"
>{$t('admin_page.next_question', { question: selected_question + 2 })}
</button>
{:else} {:else}
<button on:click={get_question_results} class="admin-button" <button on:click={get_question_results} class="admin-button"
>{$t('admin_page.show_results')} >{$t('admin_page.show_results')}
+1
View File
@@ -61,6 +61,7 @@ export interface Question {
type?: QuizQuestionType; type?: QuizQuestionType;
image?: string; image?: string;
answers: Answers; answers: Answers;
hide_results?: boolean;
} }
export type Answers = export type Answers =
+18
View File
@@ -259,6 +259,9 @@ SPDX-License-Identifier: MPL-2.0
on:click={() => { on:click={() => {
selected_create_thing = SelectedCreateThing.Create; selected_create_thing = SelectedCreateThing.Create;
}} }}
on:keyup={() => {
selected_create_thing = SelectedCreateThing.Create;
}}
class:shadow-2xl={selected_create_thing === SelectedCreateThing.Create} class:shadow-2xl={selected_create_thing === SelectedCreateThing.Create}
class:opacity-70={selected_create_thing !== SelectedCreateThing.Create} class:opacity-70={selected_create_thing !== SelectedCreateThing.Create}
> >
@@ -289,6 +292,9 @@ SPDX-License-Identifier: MPL-2.0
on:click={() => { on:click={() => {
selected_create_thing = SelectedCreateThing.Find; selected_create_thing = SelectedCreateThing.Find;
}} }}
on:keyup={() => {
selected_create_thing = SelectedCreateThing.Find;
}}
class:shadow-2xl={selected_create_thing === SelectedCreateThing.Find} class:shadow-2xl={selected_create_thing === SelectedCreateThing.Find}
class:opacity-70={selected_create_thing !== SelectedCreateThing.Find} class:opacity-70={selected_create_thing !== SelectedCreateThing.Find}
> >
@@ -396,6 +402,9 @@ SPDX-License-Identifier: MPL-2.0
on:click={() => { on:click={() => {
selected_play_thing = SelectedPlayThing.Select; selected_play_thing = SelectedPlayThing.Select;
}} }}
on:keyup={() => {
selected_play_thing = SelectedPlayThing.Select;
}}
class:shadow-2xl={selected_play_thing === SelectedPlayThing.Select} class:shadow-2xl={selected_play_thing === SelectedPlayThing.Select}
class:opacity-70={selected_play_thing !== SelectedPlayThing.Select} class:opacity-70={selected_play_thing !== SelectedPlayThing.Select}
> >
@@ -426,6 +435,9 @@ SPDX-License-Identifier: MPL-2.0
on:click={() => { on:click={() => {
selected_play_thing = SelectedPlayThing.Results; selected_play_thing = SelectedPlayThing.Results;
}} }}
on:keyup={() => {
selected_play_thing = SelectedPlayThing.Results;
}}
class:shadow-2xl={selected_play_thing === SelectedPlayThing.Results} class:shadow-2xl={selected_play_thing === SelectedPlayThing.Results}
class:opacity-70={selected_play_thing !== SelectedPlayThing.Results} class:opacity-70={selected_play_thing !== SelectedPlayThing.Results}
> >
@@ -456,6 +468,9 @@ SPDX-License-Identifier: MPL-2.0
on:click={() => { on:click={() => {
selected_play_thing = SelectedPlayThing.Winners; selected_play_thing = SelectedPlayThing.Winners;
}} }}
on:keyup={() => {
selected_play_thing = SelectedPlayThing.Winners;
}}
class:shadow-2xl={selected_play_thing === SelectedPlayThing.Winners} class:shadow-2xl={selected_play_thing === SelectedPlayThing.Winners}
class:opacity-70={selected_play_thing !== SelectedPlayThing.Winners} class:opacity-70={selected_play_thing !== SelectedPlayThing.Winners}
> >
@@ -511,6 +526,9 @@ SPDX-License-Identifier: MPL-2.0
on:click={() => { on:click={() => {
selected_classquiz_reason = index; selected_classquiz_reason = index;
}} }}
on:keyup={() => {
selected_classquiz_reason = index;
}}
class:shadow-2xl={selected_classquiz_reason === index} class:shadow-2xl={selected_classquiz_reason === index}
class:opacity-70={selected_classquiz_reason !== index} class:opacity-70={selected_classquiz_reason !== index}
> >
@@ -33,6 +33,9 @@ SPDX-License-Identifier: MPL-2.0
on:click={() => { on:click={() => {
selected_method = 'PASSKEY'; selected_method = 'PASSKEY';
}} }}
on:keyup={() => {
selected_method = 'PASSKEY';
}}
> >
<!-- heroicons/key --> <!-- heroicons/key -->
<svg <svg
@@ -61,6 +64,9 @@ SPDX-License-Identifier: MPL-2.0
on:click={() => { on:click={() => {
selected_method = 'PASSWORD'; selected_method = 'PASSWORD';
}} }}
on:keyup={() => {
selected_method = 'PASSWORD';
}}
> >
<!-- iconoir/password-cursor --> <!-- iconoir/password-cursor -->
<svg <svg
@@ -104,6 +110,9 @@ SPDX-License-Identifier: MPL-2.0
on:click={() => { on:click={() => {
selected_method = 'TOTP'; selected_method = 'TOTP';
}} }}
on:keyup={() => {
selected_method = 'TOTP';
}}
> >
<!-- heroicons/clock --> <!-- heroicons/clock -->
<svg <svg
@@ -4,6 +4,8 @@ SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
SPDX-License-Identifier: MPL-2.0 SPDX-License-Identifier: MPL-2.0
--> -->
<script lang="ts"> <script lang="ts">
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import OAuthBlock from './oauth_block.svelte'; import OAuthBlock from './oauth_block.svelte';
@@ -17,11 +19,13 @@ SPDX-License-Identifier: MPL-2.0
let isSubmitting = false; let isSubmitting = false;
$: emailEmpty = email === ''; $: emailEmpty = email === '';
const start_login = async (): Promise<void> => { const start_login = async (): Promise<void> => {
if (emailEmpty) { if (emailEmpty) {
return; return;
} }
isSubmitting = true; isSubmitting = true;
const res = await fetch('/api/v1/login/start', { const res = await fetch('/api/v1/login/start', {
method: 'post', method: 'post',
headers: { headers: {
+3
View File
@@ -60,6 +60,9 @@ SPDX-License-Identifier: MPL-2.0
connect(); connect();
} }
}); });
socket.on("session_id", (d) => {
const session_id = d.session_id
})
socket.on('registered_as_admin', (data) => { socket.on('registered_as_admin', (data) => {
quiz_data = JSON.parse(data['game']); quiz_data = JSON.parse(data['game']);
+3
View File
@@ -72,6 +72,9 @@ SPDX-License-Identifier: MPL-2.0
socket.on('time_sync', (data) => { socket.on('time_sync', (data) => {
socket.emit('echo_time_sync', data); socket.emit('echo_time_sync', data);
}); });
socket.on("session_id", (d) => {
const session_id = d.session_id
})
socket.on('connect', async () => { socket.on('connect', async () => {
console.log('Connected!'); console.log('Connected!');
+8
View File
@@ -32,6 +32,14 @@ const config = {
build: { build: {
sourcemap: true sourcemap: true
} }
/* Trying
ssr: {
noExternal: ['@ckeditor/*'],
}
end trying*/
}; };
export default config; export default config;
+8
View File
@@ -0,0 +1,8 @@
<!--
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
SPDX-License-Identifier: MPL-2.0
-->
# Migration of the Backend to Rust (long term goal)
+8 -11
View File
@@ -9,18 +9,15 @@ run_tests() {
} }
stop() { stop() {
$CONTAINER_BIN container stop classquiz_db $CONTAINER_BIN compose -f docker-compose.dev.yml down --volumes
$CONTAINER_BIN container stop test_redis
$CONTAINER_BIN container stop test_meili
} }
init() { init() {
mkdir /tmp/storage if [ ! -d /tmp/storage ]; then
$CONTAINER_BIN run --rm -d -p 6379:6379 --name test_redis redis:alpine mkdir /tmp/storage
$CONTAINER_BIN run -it --rm -d -p 7700:7700 --name test_meili getmeili/meilisearch:v0.28.0 fi
$CONTAINER_BIN volume create classquiz_db_data $CONTAINER_BIN compose -f docker-compose.dev.yml up -d
$CONTAINER_BIN run --name classquiz_db -p 5432:5432 --rm -d -e POSTGRES_PASSWORD=mysecretpassword -v classquiz_db_data:/var/lib/postgresql/data -e POSTGRES_DB=classquiz postgres sleep 2
sleep 1
pipenv run alembic upgrade head pipenv run alembic upgrade head
} }
@@ -28,14 +25,14 @@ case $1 in
+) init ;; +) init ;;
-) stop ;; -) stop ;;
a) a)
$CONTAINER_BIN volume rm classquiz_db_data $CONTAINER_BIN volume rm classquiz_db
init init
run_tests run_tests
stop stop
;; ;;
prepare) prepare)
stop stop
$CONTAINER_BIN volume rm classquiz_db_data $CONTAINER_BIN volume rm classquiz_db
init init
;; ;;
*) *)