diff --git a/Caddyfile b/Caddyfile index ee3c753..f9491b9 100644 --- a/Caddyfile +++ b/Caddyfile @@ -4,7 +4,7 @@ #* { :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 /api* localhost:8000 reverse_proxy /rapidoc* localhost:8000 diff --git a/classquiz/config (copy).py b/classquiz/config (copy).py new file mode 100644 index 0000000..b6f7635 --- /dev/null +++ b/classquiz/config (copy).py @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) +# +# SPDX-License-Identifier: MPL-2.0 + +import re +from functools import lru_cache + +from redis import asyncio as redis_lib +import redis as redis_base_lib +from pydantic import BaseSettings, RedisDsn, PostgresDsn, BaseModel +import meilisearch as MeiliSearch +from typing import Optional +from arq import create_pool +from arq.connections import RedisSettings, ArqRedis + +from classquiz.storage import Storage + + +class CustomOpenIDProvider(BaseModel): + scopes: str = "openid email profile" + server_metadata_url: str + client_id: str + client_secret: str + + +class Settings(BaseSettings): + """ + Settings class for the shop app. + """ + + root_address: str = "http://127.0.0.1:8000" + redis: RedisDsn = "redis://localhost:6379/0?decode_responses=True" + skip_email_verification: bool = False + db_url: str | PostgresDsn = "postgresql://postgres:mysecretpassword@localhost:5432/classquiz" + hcaptcha_key: str | None = None + recaptcha_key: str | None = None + mail_address: str + mail_password: str + mail_username: str + mail_server: str + mail_port: int + secret_key: str + access_token_expire_minutes: int = 30 + cache_expiry: int = 86400 + sentry_dsn: str | None + meilisearch_url: str = "http://127.0.0.1:7700" + meilisearch_index: str = "classquiz" + google_client_id: Optional[str] + google_client_secret: Optional[str] + github_client_id: Optional[str] + github_client_secret: Optional[str] + custom_openid_provider: CustomOpenIDProvider | None = None + telemetry_enabled: bool = True + free_storage_limit: int = 1074000000 + pixabay_api_key: str | None = None + mods: list[str] = [] + registration_disabled: bool = False + + # storage_backend + storage_backend: str | None = "local" + + # if storage_backend == "local": + storage_path: str | None + + # if storage_backend == "s3": + s3_access_key: str | None + s3_secret_key: str | None + s3_bucket_name: str = "classquiz" + s3_base_url: str | None + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + env_nested_delimiter = "__" + + +async def initialize_arq(): + # skipcq: PYL-W0603 + global arq + arq = await create_pool(RedisSettings.from_dsn(settings.redis)) + + +@lru_cache() +def settings() -> Settings: + return Settings() + + +pool = redis_lib.ConnectionPool().from_url(settings().redis) + +redis: redis_base_lib.client.Redis = redis_lib.Redis(connection_pool=pool) +arq: ArqRedis = ArqRedis(pool_or_conn=pool) +storage: Storage = Storage( + backend=settings().storage_backend, + storage_path=settings().storage_path, + access_key=settings().s3_access_key, + secret_key=settings().s3_secret_key, + bucket_name=settings().s3_bucket_name, + base_url=settings().s3_base_url, +) + +meilisearch = MeiliSearch.Client(settings().meilisearch_url) + +ALLOWED_TAGS_FOR_QUIZ = ["b", "strong", "i", "em", "small", "mark", "del", "sub", "sup"] + +ALLOWED_MIME_TYPES = ["image/png", "video/mp4", "image/jpeg", "image/gif", "image/webp"] + +server_regex = rf"^{re.escape(settings().root_address)}/api/v1/storage/download/.{{36}}--.{{36}}$" diff --git a/classquiz/config.py b/classquiz/config.py index b6f7635..97958f1 100644 --- a/classquiz/config.py +++ b/classquiz/config.py @@ -7,7 +7,8 @@ from functools import lru_cache from redis import asyncio as redis_lib import redis as redis_base_lib -from pydantic import BaseSettings, RedisDsn, PostgresDsn, BaseModel +from pydantic_settings import BaseSettings +from pydantic import RedisDsn, PostgresDsn, BaseModel import meilisearch as MeiliSearch from typing import Optional from arq import create_pool @@ -32,23 +33,25 @@ class Settings(BaseSettings): redis: RedisDsn = "redis://localhost:6379/0?decode_responses=True" skip_email_verification: bool = False db_url: str | PostgresDsn = "postgresql://postgres:mysecretpassword@localhost:5432/classquiz" - hcaptcha_key: str | None = None + hcaptcha_key: str = "3852831a-48b4-4236-b0de-9769a1998468" recaptcha_key: str | None = None - mail_address: str - mail_password: str - mail_username: str - mail_server: str - mail_port: int - secret_key: str +# mail_address: str + mail_address: str = "778@madeupaddress.com" +# mail_password: str + mail_password: str = "mail_passwordstring" + mail_username: str = "mail_userstring" + mail_server: str = "mail_serverstring" + mail_port: int = 0 + secret_key: str = "secret_keystring" access_token_expire_minutes: int = 30 cache_expiry: int = 86400 - sentry_dsn: str | None + sentry_dsn: str = "" meilisearch_url: str = "http://127.0.0.1:7700" meilisearch_index: str = "classquiz" - google_client_id: Optional[str] - google_client_secret: Optional[str] - github_client_id: Optional[str] - github_client_secret: Optional[str] + google_client_id: str = "google_client_idstring" + google_client_secret: str = "google_client_secretstring" + github_client_id: str = "github_client_idstring" + github_client_secret: str = "github_client_secretstring" custom_openid_provider: CustomOpenIDProvider | None = None telemetry_enabled: bool = True free_storage_limit: int = 1074000000 @@ -57,16 +60,16 @@ class Settings(BaseSettings): registration_disabled: bool = False # storage_backend - storage_backend: str | None = "local" + storage_backend: str = "local" # if storage_backend == "local": - storage_path: str | None + storage_path: str = "/tmp/storage" # if storage_backend == "s3": - s3_access_key: str | None - s3_secret_key: str | None + s3_access_key: str = "s3_access_keystring" + s3_secret_key: str = "s3_secret_keystring" s3_bucket_name: str = "classquiz" - s3_base_url: str | None + s3_base_url: str = "s3_base_urlstring" class Config: env_file = ".env" @@ -85,7 +88,7 @@ def settings() -> 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) arq: ArqRedis = ArqRedis(pool_or_conn=pool) diff --git a/classquiz/db/models (copy).py b/classquiz/db/models (copy).py new file mode 100644 index 0000000..2a9902f --- /dev/null +++ b/classquiz/db/models (copy).py @@ -0,0 +1,492 @@ +# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) +# +# SPDX-License-Identifier: MPL-2.0 + + +import os +import uuid +from datetime import datetime +from typing import Optional + +import ormar +from ormar import ReferentialAction +from pydantic import BaseModel, Json, validator +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) + + class Meta: + tablename = "users" + metadata = metadata + database = database + + class Config: + 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) + + class Meta: + 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) + + class Meta: + 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()) + + class Meta: + tablename = "user_sessions" + metadata = metadata + database = database + + +class ABCDQuizAnswer(BaseModel): + right: bool + answer: str + color: str | 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 + + +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 + + @validator("answers") + def answers_not_none_if_abcd_type(cls, v, values): + if values["type"] == QuizQuestionType.ABCD and not isinstance(v[0], ABCDQuizAnswer): + raise ValueError("Answers can't be none if type is ABCD") + if values["type"] == QuizQuestionType.RANGE and not isinstance(v, RangeQuizAnswer): + raise ValueError("Answer must be from type RangeQuizAnswer if type is RANGE") + if values["type"] == QuizQuestionType.VOTING and not isinstance(v[0], VotingQuizAnswer): + raise ValueError("Answer must be from type VotingQuizAnswer if type is VOTING") + if values["type"] == QuizQuestionType.TEXT and not isinstance(v[0], TextQuizAnswer): + raise ValueError("Answer must be from type TextQuizAnswer if type is TEXT") + if values["type"] == QuizQuestionType.ORDER and not isinstance(v[0], VotingQuizAnswer): + raise ValueError("Answer must be from type VotingQuizAnswer if type is ORDER") + if values["type"] == QuizQuestionType.SLIDE and not isinstance(v, str): + raise ValueError("Answer must be from type SlideElement if type is SLIDE") + if values["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 = False + title: str + description: str + cover_image: str | None + background_color: str | None + questions: list[QuizQuestion] + background_image: str | 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) + + class Meta: + 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) + + class Meta: + 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 + game_mode: str | None + current_question: int = -1 + background_color: str | None + background_image: str | None + custom_field: str | None + question_show: bool = False + + +class GamePlayer(BaseModel): + username: str + sid: str | None + + +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] + + +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(BaseModel): + # Just a method to make a top-level list + __root__: list[AnswerData] + + +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) + + class Meta: + 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) + + class Meta: + 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) + + class Meta: + tablename = "quiztivityshares" + metadata = metadata + database = database + + +class OnlyId(BaseModel): + id: uuid.UUID + + +class PublicQuizTivityShare(BaseModel): + id: uuid.UUID + name: str | None + expire_in: int | 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) + + class Meta: + tablename = "storage_items" + metadata = metadata + database = database + + +class PublicStorageItem(BaseModel): + id: uuid.UUID + uploaded_at: datetime + mime_type: str + hash: str | None + size: int + deleted_at: datetime | None + alt_text: str | None + filename: str | None + thumbhash: str | None + server: str | 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 + alt_text: str | 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) + + class Meta: + 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) + + class Meta: + tablename = "rating" + metadata = metadata + database = database diff --git a/classquiz/db/models.py b/classquiz/db/models.py index 2a9902f..b9aa914 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -45,13 +45,11 @@ class User(ormar.Model): 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) - class Meta: - tablename = "users" - metadata = metadata - database = database - - class Config: - use_enum_values = True + ormar_config = ormar.OrmarConfig( + database=database, + metadata=metadata, + tablename="users" + ) class FidoCredentials(ormar.Model): @@ -61,20 +59,22 @@ class FidoCredentials(ormar.Model): sign_count: int = ormar.Integer() user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) - class Meta: - tablename = "fido_credentials" - metadata = metadata - database = database + ormar_config = ormar.OrmarConfig( + database=database, + metadata=metadata, + tablename="fido_credentials" + ) 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) - class Meta: - tablename = "api_keys" - metadata = metadata - database = database + ormar_config = ormar.OrmarConfig( + database=database, + metadata=metadata, + tablename="api_keys" + ) class UserSession(ormar.Model): @@ -90,10 +90,11 @@ class UserSession(ormar.Model): user_agent: str = ormar.String(max_length=255, nullable=True) last_seen: datetime = ormar.DateTime(default=datetime.now()) - class Meta: - tablename = "user_sessions" - metadata = metadata - database = database + ormar_config = ormar.OrmarConfig( + database=database, + metadata=metadata, + tablename="user_sessions" + ) class ABCDQuizAnswer(BaseModel): @@ -186,19 +187,25 @@ class Quiz(ormar.Model): views: int = ormar.Integer(nullable=False, default=0, server_default="0") mod_rating: int | None = ormar.SmallInteger(nullable=True) - class Meta: - tablename = "quiz" - metadata = metadata + 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) - class Meta: - tablename = "instance_data" - metadata = metadata +# class Meta: +# tablename = "instance_data" +# metadata = metadata +# database = database + ormar_config = ormar.OrmarConfig( + tablename = "instance_data", + metadata = metadata, database = database +) class Token(BaseModel): @@ -275,7 +282,7 @@ class AnswerData(BaseModel): class AnswerDataList(BaseModel): # Just a method to make a top-level list - __root__: list[AnswerData] + root: list[AnswerData] class GameInLobby(BaseModel): @@ -307,10 +314,11 @@ class GameResults(ormar.Model): description: str = ormar.Text(nullable=False) questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False) - class Meta: - tablename = "game_results" - metadata = metadata - database = database + ormar_config = ormar.OrmarConfig( + database=database, + metadata=metadata, + tablename="game_results" + ) class QuizTivityInput(BaseModel): @@ -325,10 +333,11 @@ class QuizTivity(ormar.Model): user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) pages: list[QuizTivityPage] = ormar.JSON(nullable=False) - class Meta: - tablename = "quiztivitys" - metadata = metadata + ormar_config = ormar.OrmarConfig( + tablename = "quiztivitys", + metadata = metadata, database = database + ) class QuizTivityShare(ormar.Model): @@ -338,10 +347,11 @@ class QuizTivityShare(ormar.Model): quiztivity: QuizTivity | None = ormar.ForeignKey(QuizTivity, ondelete=ReferentialAction.CASCADE) user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) - class Meta: - tablename = "quiztivityshares" - metadata = metadata - database = database + ormar_config = ormar.OrmarConfig( + database=database, + metadata=metadata, + tablename="quiztivityshares" + ) class OnlyId(BaseModel): @@ -386,10 +396,11 @@ class StorageItem(ormar.Model): server: str | None = ormar.Text(default=None, nullable=True) imported: bool = ormar.Boolean(default=False, nullable=True) - class Meta: - tablename = "storage_items" - metadata = metadata - database = database + ormar_config = ormar.OrmarConfig( + database=database, + metadata=metadata, + tablename="storage_items" + ) class PublicStorageItem(BaseModel): @@ -473,10 +484,11 @@ class Controller(ormar.Model): os_version: str | None = ormar.Text(nullable=True) wanted_os_version: str = ormar.Text(nullable=True, default=None) - class Meta: - tablename = "controller" - metadata = metadata + ormar_config = ormar.OrmarConfig( + tablename = "controller", + metadata = metadata, database = database +) class Rating(ormar.Model): @@ -486,7 +498,8 @@ class Rating(ormar.Model): created_at: datetime = ormar.DateTime(nullable=False, server_default=func.now()) quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz) - class Meta: - tablename = "rating" - metadata = metadata - database = database + ormar_config = ormar.OrmarConfig( + database=database, + metadata=metadata, + tablename="rating" + ) diff --git a/classquiz/routers/login.py b/classquiz/routers/login.py index a88d33e..5dae4a3 100644 --- a/classquiz/routers/login.py +++ b/classquiz/routers/login.py @@ -102,19 +102,27 @@ def verify_webauthn(data, fidocredentialss: list[FidoCredentials], login_session @router.post("/start") async def start_login(data: StartLoginInput): user = ( +#TODO Find out why the fidocredentialls call fails await User.objects.select_related("fidocredentialss") .filter((User.email == data.email) | (User.username == data.email)) .get_or_none() +# await User.objects.filter((User.email == data.email) | (User.username == data.email)) +# .get_or_none() ) + print("User.objects.filter call completed...") step_1: set[StartLoginResponseTypes] = set() step_2: set[StartLoginResponseTypes] = set() webauthn_data = None webauthn_challenge = None + print("\"webauthn_challenge = None\" call completed...") + print("user.verified: " + str(user.verified)) if user is None or not user.verified: step_1.add(StartLoginResponseTypes.PASSWORD) return StartLoginResponse(step_1=step_1, step_2=step_2, session_id=os.urandom(16).hex(), webauthn_data=None) + print("Okay. User is not None or unverified...") if user.password is not None: step_1.add(StartLoginResponseTypes.PASSWORD) + ''' TODO Find out why fidocredentialss is causing problems if len(user.fidocredentialss) > 0: if user.require_password is True: step_2.add(StartLoginResponseTypes.PASSKEY) @@ -129,6 +137,8 @@ async def start_login(data: StartLoginInput): ) webauthn_challenge = base64.b64encode(webauthn_data.challenge).decode("utf-8") webauthn_data = options_to_json(webauthn_data) +''' + print("Moving past commented code, the long string one...") if user.totp_secret is not None: if user.require_password: step_2.add(StartLoginResponseTypes.TOTP) @@ -170,7 +180,8 @@ async def step_1_endpoint(session_id: str, data: StepInput, request: Request, re else: print("unknown step") raise HTTPException(401) - user = await User.objects.select_related("fidocredentialss").get_or_none(id=uuid.UUID(login_session.user_id)) +#TODO fidocredentials bug fix user = await User.objects.select_related("fidocredentialss").get_or_none(id=uuid.UUID(login_session.user_id)) + user = await User.objects.get_or_none(id=uuid.UUID(login_session.user_id)) if data.auth_type == StartLoginResponseTypes.PASSWORD: if verify_password(data.data, user.password): if len(login_session.step_2) == 0 or (step_id == 2 and login_session.step1_success is True): diff --git a/classquiz/routers/quiz.py b/classquiz/routers/quiz.py index 9d742a7..9d658a2 100644 --- a/classquiz/routers/quiz.py +++ b/classquiz/routers/quiz.py @@ -15,7 +15,7 @@ import ormar.exceptions from classquiz.helpers import generate_spreadsheet, handle_import_from_excel from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi.responses import JSONResponse, StreamingResponse -from pydantic import ValidationError, BaseModel +from pydantic import ValidationError, BaseModel, Field from classquiz.auth import get_current_user from classquiz.config import redis, settings, storage, meilisearch @@ -56,7 +56,8 @@ class PublicQuizResponseUser(BaseModel): class PublicQuizResponse(Quiz.get_pydantic()): user_id: PublicQuizResponseUser - questions: list[QuizQuestion] +# questions: list[QuizQuestion] + var_questions: list[QuizQuestion] = Field(..., alias='questions') likes: int dislikes: int views: int diff --git a/classquiz/routers/sitemap (copy).py b/classquiz/routers/sitemap (copy).py new file mode 100644 index 0000000..441a8d3 --- /dev/null +++ b/classquiz/routers/sitemap (copy).py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) +# +# SPDX-License-Identifier: MPL-2.0 + + +import datetime +import uuid + +from fastapi import APIRouter, Response +from jinja2 import Template +from pydantic import BaseModel +from classquiz.config import redis, settings +from classquiz.db import database + +settings = settings() +router = APIRouter() + + +sitemap_template = """ + +{% for entry in pages -%} + + {{ root_address}}/view/{{ entry.id.hex }} + {%- if entry.updated_at != None -%} + {{ entry.updated_at.strftime("%Y-%m-%d") }} + {% endif %} + + {%- endfor %} +""" + + +sql_statement_metadata = """ +SELECT id, title, description, updated_at from quiz where public ='t' +""" + +sql_statement_id_and_modified_only = """ +SELECT id, updated_at from quiz where public ='t' +""" + +template = Template(sitemap_template, enable_async=True) + + +class SitemapQuiz(BaseModel): + id: uuid.UUID + updated_at: datetime.datetime + + class Config: + orm_mode = True + + +@router.get("/get") +async def get_sitemap(): + redis_cache_resp = await redis.get("sitemap") + if redis_cache_resp is None: + res = await database.fetch_all(sql_statement_id_and_modified_only) + entries = [] + for i in res: + entries.append(SitemapQuiz.from_orm(i)) + rendered_sitemap = await template.render_async({"pages": entries, "root_address": settings.root_address}) + await redis.set("sitemap", rendered_sitemap, ex=86400) + return Response(content=rendered_sitemap, media_type="application/xml") + else: + return Response(content=redis_cache_resp, media_type="application/xml") diff --git a/classquiz/routers/sitemap.py b/classquiz/routers/sitemap.py index 441a8d3..af79b39 100644 --- a/classquiz/routers/sitemap.py +++ b/classquiz/routers/sitemap.py @@ -45,7 +45,7 @@ class SitemapQuiz(BaseModel): updated_at: datetime.datetime class Config: - orm_mode = True + from_attributes = True @router.get("/get") diff --git a/classquiz/worker/__init__.py b/classquiz/worker/__init__.py index 559b827..ad99b24 100644 --- a/classquiz/worker/__init__.py +++ b/classquiz/worker/__init__.py @@ -27,4 +27,4 @@ class WorkerSettings: cron_jobs = [cron(clean_editor_images_up, hour={0, 6, 12, 18}, minute=0)] on_startup = startup on_shutdown = shutdown - redis_settings = RedisSettings.from_dsn(settings.redis) + redis_settings = RedisSettings.from_dsn(str(settings.redis)) diff --git a/docker-compose (copy).yml b/docker-compose (copy).yml new file mode 100644 index 0000000..45e3fec --- /dev/null +++ b/docker-compose (copy).yml @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) +# +# SPDX-License-Identifier: MPL-2.0 + +version: "3" + +services: + frontend: + restart: always + build: + context: ./frontend + dockerfile: Dockerfile + depends_on: + - redis + - api + environment: + REDIS_URL: redis://redis:6379/0?decode_responses=True + API_URL: http://api:80 + api: + build: &build_cfg + context: . + dockerfile: Dockerfile + restart: &restart always + depends_on: &depends + - db + - redis + + environment: &env_vars + # --- DON'T CHANGE FROM HERE --- + DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz" # 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 + MAX_WORKERS: "1" # Very important and DON'T CHANGE + ACCESS_TOKEN_EXPIRE_MINUTES: 30 # DON'T CHANGE + MEILISEARCH_URL: "http://meilisearch:7700" # DON'T CHANGE + # -- DON'T CHANGE TILL HERE --- + + # --- GENERAL CONFI --- + ROOT_ADDRESS: "https://classquiz.de" # CHANGE IT (without a "/" at the end) + + # --- MAIL CONFIG --- + MAIL_PORT: "587" + MAIL_ADDRESS: "email@email@email.email" + MAIL_PASSWORD: "PASSWORT" + MAIL_USERNAME: "email@email@email.email" + MAIL_SERVER: "email@email@email.emai" + SKIP_EMAIL_VERIFICATION: "True" # Does the user have to confirm its email by clicking a link? + + # --- EXTERNAL API CONFIG --- + # HCAPTCHA_KEY: "HCAPTCHA_PRIVATE_KEY" + # PIXABAY_API_KEY: "" # Get it from here: https://pixabay.com/api/docs/ + # RECAPTCHA_KEY: "" Get it from Google for the Captcha. + + # -- STORAGE CONFIG --- + STORAGE_BACKEND: "local" # Could also be s3 + STORAGE_PATH: "/app/data" # When s3 is used, this isn't needed. + # If STORAGE_BACKEND is "s3" + #S3_ACCESS_KEY: "YOUR_ACCESS_KEY" + #S3_SECRET_KEY: "YOUR_SECRET_KEY" + #S3_BASE_URL: "YOUR_S3_BASE_URL" + + # --- GOOGLE_AUTH --- + #GOOGLE_CLIENT_ID: "" # Your Google-Client ID, or leave it unset if you don't want it. + #GOOGLE_CLIENT_SECRET: "" # Your Google-Client Secret, or leave it unset if you don't want it. + + # --- GITHUB_AUTH --- + #GITHUB_CLIENT_ID: "" # Your GitHub-Client ID, or leave it unset if you don't want it. + #GITHUB_CLIENT_SECRET: "" # Your GitHub-Client Secret, or leave it unset if you don't want it. + + # --- Custom OpenID --- + #CUSTOM_OPENID_PROVIDER__CLIENT_ID: "" # Adjust if needed + #CUSTOM_OPENID_PROVIDER__CLIENT_SECRET: "" # Adjust if needed + #CUSTOM_OPENID_PROVIDER__SERVER_METADATA_URL: "/.well-known/openid-configuration" # Adjust if needed + + volumes: # Only needed if you chose the "local" storage-backend + - ./uploads:/var/storage + + redis: + image: redis:alpine + restart: always + healthcheck: + test: [ "CMD", "redis-cli","ping" ] + + db: + image: postgres:14-alpine + restart: always + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres" ] + interval: 5s + timeout: 5s + retries: 5 + environment: + POSTGRES_PASSWORD: "classquiz" + POSTGRES_DB: "classquiz" + volumes: + - data:/var/lib/postgresql/data + proxy: + image: caddy:alpine + restart: always + volumes: + - ./Caddyfile-docker:/etc/caddy/Caddyfile + ports: + - "8000:8080" # The 8000 can be changed. + meilisearch: + image: getmeili/meilisearch:v0.28.0 + restart: always + environment: + MEILI_NO_ANALYTICS: true + volumes: + - meilisearch-data:/data.ms + worker: + build: *build_cfg + environment: *env_vars + depends_on: *depends + restart: *restart + command: arq classquiz.worker.WorkerSettings + + +volumes: + data: + meilisearch-data: diff --git a/docker-compose.yml b/docker-compose.yml index 45e3fec..8a409dd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: # --- DON'T CHANGE FROM HERE --- DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz" # 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 ACCESS_TOKEN_EXPIRE_MINUTES: 30 # DON'T CHANGE MEILISEARCH_URL: "http://meilisearch:7700" # DON'T CHANGE @@ -39,15 +39,15 @@ services: ROOT_ADDRESS: "https://classquiz.de" # CHANGE IT (without a "/" at the end) # --- MAIL CONFIG --- - MAIL_PORT: "587" - MAIL_ADDRESS: "email@email@email.email" - MAIL_PASSWORD: "PASSWORT" - MAIL_USERNAME: "email@email@email.email" - MAIL_SERVER: "email@email@email.emai" + MAIL_PORT: "993" + MAIL_ADDRESS: "temp" + MAIL_PASSWORD: "temp" + MAIL_USERNAME: "temp" + MAIL_SERVER: "imap.gmail.com" SKIP_EMAIL_VERIFICATION: "True" # Does the user have to confirm its email by clicking a link? # --- EXTERNAL API CONFIG --- - # HCAPTCHA_KEY: "HCAPTCHA_PRIVATE_KEY" + # HCAPTCHA_KEY: "3852831a-48b4-4236-b0de-9769a1998468" # PIXABAY_API_KEY: "" # Get it from here: https://pixabay.com/api/docs/ # RECAPTCHA_KEY: "" Get it from Google for the Captcha. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 8f1192b..93f8086 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -11,11 +11,12 @@ ENV API_URL=https://mawoka.eu ENV REDIS_URL=redis://localhost:6379 ENV VITE_MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoibWF3b2thIiwiYSI6ImNsMjBob3d4ZjBhcGszYnE0bWp4aXB1ZW4ifQ.IByxV1qeIuEWpHCWsuB88A # 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=3852831a-48b4-4236-b0de-9769a1998468 +# Hacaptcha secret ES_e0f8139cb5ad467d892e8d73a020cd2b # ENV VITE_SENTRY=https://75cb4ef1be624d8f81bbaf864b722f8a@glitch.mawoka.eu/2 #ENV VITE_GOOGLE_AUTH_ENABLED=true #ENV VITE_GITHUB_AUTH_ENABLED=true -#ENV VITE_CAPTCHA_ENABLED=true +ENV VITE_CAPTCHA_ENABLED=true #ENV VITE_REGISTRATION_DISABLED=True #ENV VITE_PLAUSIBLE_DATA_URL= # change working directory diff --git a/frontend/Dockerfile (copy) b/frontend/Dockerfile (copy) new file mode 100644 index 0000000..8f1192b --- /dev/null +++ b/frontend/Dockerfile (copy) @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) +# +# SPDX-License-Identifier: MPL-2.0 + +### Build Step +# pull the Node.js Docker image +FROM node:19-bullseye as builder +# ENV API_URL=http://api:80 +ENV API_URL=https://mawoka.eu +#Ah, I've noticed that!!! +ENV REDIS_URL=redis://localhost:6379 +ENV VITE_MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoibWF3b2thIiwiYSI6ImNsMjBob3d4ZjBhcGszYnE0bWp4aXB1ZW4ifQ.IByxV1qeIuEWpHCWsuB88A +# 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_SENTRY=https://75cb4ef1be624d8f81bbaf864b722f8a@glitch.mawoka.eu/2 +#ENV VITE_GOOGLE_AUTH_ENABLED=true +#ENV VITE_GITHUB_AUTH_ENABLED=true +#ENV VITE_CAPTCHA_ENABLED=true +#ENV VITE_REGISTRATION_DISABLED=True +#ENV VITE_PLAUSIBLE_DATA_URL= +# change working directory +WORKDIR /usr/src/app + + +# copy the package.json files from local machine to the workdir in container +COPY package*.json ./ +COPY pnpm-lock.yaml ./ + +# run npm install in our local machine +RUN corepack enable && corepack prepare pnpm@8.14.0 --activate && pnpm i + +# copy the generated modules and all other files to the container +COPY . . + +# build the application +RUN pnpm run build + +### Serve Step +# pull the Node.js Docker image +FROM node:19-bullseye-slim + +# change working directory +WORKDIR /app +COPY --from=builder /usr/src/app/package.json . +COPY --from=builder /usr/src/app/pnpm-lock.yaml . +RUN corepack enable && corepack prepare pnpm@8.14.0 --activate && pnpm i +# copy files from previous step +COPY --from=builder /usr/src/app/build . +COPY --from=builder /usr/src/app/node_modules ./node_modules + +# our app is running on port 3000 within the container, so need to expose it +EXPOSE 3000 + +# the command that starts our app +CMD ["pnpm", "run", "run:prod"] diff --git a/frontend/package (copy).json b/frontend/package (copy).json new file mode 100644 index 0000000..28e3072 --- /dev/null +++ b/frontend/package (copy).json @@ -0,0 +1,109 @@ +{ + "name": "frontend", + "version": "0.0.1", + "scripts": { + "dev": "vite dev", + "build": "NODE_ENV=production vite build", + "package": "vite package", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . '!src/lib/i18n/locales/*.json' '!pnpm-lock.yaml' '!src/app.html' && eslint --ignore-path .gitignore .", + "lint-without-format-checking": "eslint --ignore-path .gitignore .", + "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. . '!src/lib/i18n/locales/*.json'", + "run:prod": "node index.js", + "translations-scan": "i18next-scanner --config i18next-scanner.config.engine.cjs src/**/*.svelte" + }, + "devDependencies": { + "@beyonk/svelte-mapbox": "^9.0.5", + "@ckeditor/ckeditor5-autoformat": "^37.1.0", + "@ckeditor/ckeditor5-basic-styles": "^37.1.0", + "@ckeditor/ckeditor5-build-balloon": "^37.1.0", + "@ckeditor/ckeditor5-editor-balloon": "^37.1.0", + "@ckeditor/ckeditor5-essentials": "^37.1.0", + "@ckeditor/ckeditor5-theme-lark": "^37.1.0", + "@felte/reporter-tippy": "^1.1.5", + "@felte/validator-yup": "^1.0.11", + "@ffmpeg/core": "^0.11.0", + "@ffmpeg/ffmpeg": "^0.11.6", + "@fontsource/marck-script": "^4.5.11", + "@sentry/browser": "^7.48.0", + "@sentry/tracing": "^7.48.0", + "@simplewebauthn/browser": "^7.2.0", + "@sveltejs/adapter-auto": "^2.0.0", + "@sveltejs/adapter-node": "^1.2.3", + "@sveltejs/kit": "^1.15.7", + "@tailwindcss/typography": "^0.5.9", + "@types/canvas-confetti": "^1.6.0", + "@types/cookie": "^0.5.1", + "@types/dompurify": "^3.0.2", + "@types/js-cookie": "^3.0.3", + "@types/luxon": "^3.3.0", + "@types/marked": "^4.3.0", + "@types/qrcode": "^1.5.0", + "@types/sortablejs": "^1.15.1", + "@types/ua-parser-js": "^0.7.36", + "@typescript-eslint/eslint-plugin": "^5.59.0", + "@typescript-eslint/parser": "^5.59.0", + "@unlazy/svelte": "^0.8.9", + "@uppy/compressor": "^1.0.2", + "@uppy/core": "^3.2.0", + "@uppy/dashboard": "^3.4.0", + "@uppy/drag-drop": "^3.0.2", + "@uppy/drop-target": "^2.0.1", + "@uppy/image-editor": "^2.1.2", + "@uppy/progress-bar": "^3.0.2", + "@uppy/status-bar": "^3.1.1", + "@uppy/svelte": "^3.0.2", + "@uppy/xhr-upload": "^3.2.0", + "autoprefixer": "^10.4.14", + "canvas-confetti": "^1.6.0", + "cookie": "^0.5.0", + "crypto-js": "^4.2.0", + "cssnano": "^6.0.0", + "dompurify": "^3.0.3", + "eslint": "^8.38.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-svelte3": "^4.0.0", + "felte": "^1.2.7", + "fuse.js": "^6.6.2", + "highlight.js": "^11.7.0", + "i18next": "^22.4.15", + "i18next-browser-languagedetector": "^7.0.1", + "js-cookie": "^3.0.1", + "jws": "^4.0.0", + "luxon": "^3.3.0", + "mapbox-gl": "^2.14.1", + "marked": "^5.0.0", + "mdsvex": "^0.10.6", + "minisearch": "^6.0.1", + "pikaso": "^2.7.6", + "plausible-tracker": "^0.3.8", + "postcss": "^8.4.31", + "postcss-import": "^15.1.0", + "postcss-load-config": "^4.0.1", + "prettier": "^2.8.8", + "prettier-plugin-svelte": "^2.10.1", + "qrcode": "^1.5.1", + "sass": "^1.62.0", + "socket.io-client": "^4.6.1", + "svelte": "^3.58.0", + "svelte-check": "^3.2.0", + "svelte-preprocess": "^5.0.3", + "svelte-range-slider-pips": "^2.1.1", + "svelte-tippy": "^1.3.2", + "swiper": "^8.4.7", + "tailwindcss": "^3.3.1", + "thumbhash": "^0.1.1", + "tinykeys": "^2.1.0", + "tippy.js": "^6.3.7", + "tslib": "^2.5.0", + "typescript": "~5.0.4", + "ua-parser-js": "^1.0.35", + "vite": "^4.2.3", + "vite-plugin-cross-origin-isolation": "^0.1.6", + "vite-plugin-iso-import": "^1.0.0", + "yup": "^1.1.1" + }, + "type": "module" +} diff --git a/frontend/package.json b/frontend/package.json index 28e3072..7704adf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,12 +16,12 @@ }, "devDependencies": { "@beyonk/svelte-mapbox": "^9.0.5", - "@ckeditor/ckeditor5-autoformat": "^37.1.0", - "@ckeditor/ckeditor5-basic-styles": "^37.1.0", - "@ckeditor/ckeditor5-build-balloon": "^37.1.0", - "@ckeditor/ckeditor5-editor-balloon": "^37.1.0", - "@ckeditor/ckeditor5-essentials": "^37.1.0", - "@ckeditor/ckeditor5-theme-lark": "^37.1.0", + "@ckeditor/ckeditor5-autoformat": "^41.1.0", + "@ckeditor/ckeditor5-basic-styles": "^41.1.0", + "@ckeditor/ckeditor5-build-balloon": "^41.1.0", + "@ckeditor/ckeditor5-editor-balloon": "^41.1.0", + "@ckeditor/ckeditor5-essentials": "^41.1.0", + "@ckeditor/ckeditor5-theme-lark": "^41.1.0", "@felte/reporter-tippy": "^1.1.5", "@felte/validator-yup": "^1.0.11", "@ffmpeg/core": "^0.11.0", diff --git a/frontend/src/lib/components/BACKUPcommandpalette (copy).svelteBACKUP b/frontend/src/lib/components/BACKUPcommandpalette (copy).svelteBACKUP new file mode 100644 index 0000000..a178e3a --- /dev/null +++ b/frontend/src/lib/components/BACKUPcommandpalette (copy).svelteBACKUP @@ -0,0 +1,279 @@ + + + + +{#if open} +
+
+
+

+ {bg_text} +

+ +
+
+ {#each visible_items as vi, i} +
(selected = i)} + on:mousedown={execute_action} + > +
+

{vi.title}

+

+ /{vi.command} + {#if vi.args} + {#each vi.args as arg} + {{arg}}{/each} + {/if} +

+
+

{vi.description}

+
+ {/each} +
+
+
+{/if} diff --git a/frontend/src/lib/components/commandpalette.svelte b/frontend/src/lib/components/commandpalette.svelte index a178e3a..e1ed2e3 100644 --- a/frontend/src/lib/components/commandpalette.svelte +++ b/frontend/src/lib/components/commandpalette.svelte @@ -4,6 +4,10 @@ SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) SPDX-License-Identifier: MPL-2.0 --> + + + + + ClassQuiz - {$t('index_page.meta.title')} + + ClassQuiz - Home + + + + + + + + + + + + + + + + + +
+
+
+

ClassQuiz

+

{$t('index_page.slogan')}

+
+
+
+

{$t('index_page.how_does_classquiz_work')}

+ +
+

+ {$t('index_page.get_a_quiz')} +

+
+
+
+
+ {#if selected_create_thing === SelectedCreateThing.Create} + Screenshot of the import-page showing an URL to Kahoot! entered + {:else if selected_create_thing === SelectedCreateThing.Find} + Screenshot of the search-page showing one found quiz for the term 'Country' + {:else if selected_create_thing === SelectedCreateThing.Import} + Screenshot of the import-page showing an URL to Kahoot! entered + {:else} +

Shouldn't happen!

+ {/if} +
+
+
+
{ + selected_create_thing = SelectedCreateThing.Create; + }} + class:shadow-2xl={selected_create_thing === SelectedCreateThing.Create} + class:opacity-70={selected_create_thing !== SelectedCreateThing.Create} + > +
+ + + +
+
{$t('words.create')}
+

{$t('index_page.create_a_quiz_from_scratch')}

+
+
{ + selected_create_thing = SelectedCreateThing.Find; + }} + class:shadow-2xl={selected_create_thing === SelectedCreateThing.Find} + class:opacity-70={selected_create_thing !== SelectedCreateThing.Find} + > +
+ + + +
+
{$t('words.find')}
+

{$t('index_page.find_or_explore')}

+
+ +
+
+
+ +
+
+

+ {$t('index_page.play_quiz')} +

+
+ +
+
+
+ {#if selected_play_thing === SelectedPlayThing.Select} + Screenshot of the screen where an answer can be selected + {:else if selected_play_thing === SelectedPlayThing.Results} + Screenshot of the results with a table showing how many players chose which answer + {:else if selected_play_thing === SelectedPlayThing.Winners} + Screenshot of the import-page showing an URL to Kahoot! entered + {:else} +

Shouldn't happen!

+ {/if} +
+
+
+
{ + selected_play_thing = SelectedPlayThing.Select; + }} + class:shadow-2xl={selected_play_thing === SelectedPlayThing.Select} + class:opacity-70={selected_play_thing !== SelectedPlayThing.Select} + > +
+ + + +
+
{$t('index_page.select_answer')}
+

{$t('index_page.choose_answer_wisely')}

+
+
{ + selected_play_thing = SelectedPlayThing.Results; + }} + class:shadow-2xl={selected_play_thing === SelectedPlayThing.Results} + class:opacity-70={selected_play_thing !== SelectedPlayThing.Results} + > +
+ + + +
+
{$t('index_page.view_results')}
+

{$t('index_page.check_if_chosen_wisely')}

+
+
{ + selected_play_thing = SelectedPlayThing.Winners; + }} + class:shadow-2xl={selected_play_thing === SelectedPlayThing.Winners} + class:opacity-70={selected_play_thing !== SelectedPlayThing.Winners} + > +
+ + + +
+
{$t('index_page.list_winners')}
+

{$t('index_page.get_ranking_and_winners')}

+
+
+
+
+ +
+
+

+ {$t('index_page.why_classquiz')} +

+
+ +
+
+
+

+ {classquiz_reasons[selected_classquiz_reason].content} +

+
+
+
+ {#each classquiz_reasons as reason, index} +
{ + selected_classquiz_reason = index; + }} + class:shadow-2xl={selected_classquiz_reason === index} + class:opacity-70={selected_classquiz_reason !== index} + > +
{reason.headline}
+
+ {/each} +
+
+
+
+{#if newsletterModalOpen} +
+ +
+{/if} +