✨ Added background-image to quizzes
This commit is contained in:
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
import re
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from redis import asyncio as redis_lib
|
from redis import asyncio as redis_lib
|
||||||
@@ -71,3 +71,5 @@ storage: Storage = Storage(
|
|||||||
meilisearch = MeiliSearch.Client(settings().meilisearch_url)
|
meilisearch = MeiliSearch.Client(settings().meilisearch_url)
|
||||||
|
|
||||||
ALLOWED_TAGS_FOR_QUIZ = ["b", "strong", "i", "em", "small", "mark", "del", "sub", "sup"]
|
ALLOWED_TAGS_FOR_QUIZ = ["b", "strong", "i", "em", "small", "mark", "del", "sub", "sup"]
|
||||||
|
|
||||||
|
server_regex = rf"^{re.escape(settings().root_address)}/api/v1/storage/download/.{{36}}--.{{36}}$"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -10,6 +11,7 @@ import ormar
|
|||||||
from pydantic import BaseModel, Json, validator
|
from pydantic import BaseModel, Json, validator
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from . import metadata, database
|
from . import metadata, database
|
||||||
|
from ..config import server_regex
|
||||||
|
|
||||||
|
|
||||||
class UserAuthTypes(Enum):
|
class UserAuthTypes(Enum):
|
||||||
@@ -150,6 +152,16 @@ class QuizInput(BaseModel):
|
|||||||
cover_image: str | None
|
cover_image: str | None
|
||||||
background_color: str | None
|
background_color: str | None
|
||||||
questions: list[QuizQuestion]
|
questions: list[QuizQuestion]
|
||||||
|
background_image: str | None
|
||||||
|
|
||||||
|
@validator("background_image")
|
||||||
|
def must_come_from_local_cdn(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
elif bool(re.match(server_regex, v)):
|
||||||
|
return v
|
||||||
|
else:
|
||||||
|
raise ValueError("does not match url scheme")
|
||||||
|
|
||||||
|
|
||||||
class Quiz(ormar.Model):
|
class Quiz(ormar.Model):
|
||||||
@@ -164,6 +176,16 @@ class Quiz(ormar.Model):
|
|||||||
imported_from_kahoot: Optional[bool] = ormar.Boolean(default=False, nullable=True)
|
imported_from_kahoot: Optional[bool] = ormar.Boolean(default=False, nullable=True)
|
||||||
cover_image: Optional[str] = ormar.Text(nullable=True, unique=False)
|
cover_image: Optional[str] = ormar.Text(nullable=True, unique=False)
|
||||||
background_color: str | None = 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)
|
||||||
|
|
||||||
|
@validator("background_image")
|
||||||
|
def must_come_from_local_cdn(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
elif bool(re.match(server_regex, v)):
|
||||||
|
return v
|
||||||
|
else:
|
||||||
|
raise ValueError("does not match url scheme")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
tablename = "quiz"
|
tablename = "quiz"
|
||||||
@@ -211,6 +233,7 @@ class PlayGame(BaseModel):
|
|||||||
game_mode: str | None
|
game_mode: str | None
|
||||||
current_question: int = -1
|
current_question: int = -1
|
||||||
background_color: str | None
|
background_color: str | None
|
||||||
|
background_image: str | None
|
||||||
custom_field: str | None
|
custom_field: str | None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import bleach
|
|||||||
from fastapi import APIRouter, File, UploadFile, HTTPException, Depends
|
from fastapi import APIRouter, File, UploadFile, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ
|
from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, server_regex
|
||||||
from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType
|
from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType
|
||||||
import puremagic
|
import puremagic
|
||||||
from classquiz.auth import get_current_user
|
from classquiz.auth import get_current_user
|
||||||
@@ -143,10 +143,10 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
|
|||||||
)
|
)
|
||||||
image_id_regex = r"^.{36}--.{36}$"
|
image_id_regex = r"^.{36}--.{36}$"
|
||||||
imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$"
|
imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$"
|
||||||
server_regex = rf"^{re.escape(settings.root_address)}/api/v1/storage/download/.{{36}}--.{{36}}$"
|
|
||||||
extract_file_name_re = r"^.*/api/v1/storage/download/(.{36}--.{36})$"
|
extract_file_name_re = r"^.*/api/v1/storage/download/(.{36}--.{36})$"
|
||||||
images_to_delete = []
|
images_to_delete = []
|
||||||
old_quiz_data = await Quiz.objects.get_or_none(id=session_data.quiz_id, user_id=session_data.user_id)
|
old_quiz_data: Quiz = await Quiz.objects.get_or_none(id=session_data.quiz_id, user_id=session_data.user_id)
|
||||||
|
|
||||||
def mark_image_for_deletion(new: str | None, index: int, old_quiz: Quiz | None):
|
def mark_image_for_deletion(new: str | None, index: int, old_quiz: Quiz | None):
|
||||||
if old_quiz is None:
|
if old_quiz is None:
|
||||||
@@ -183,6 +183,9 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
|
|||||||
if quiz_input.cover_image == "":
|
if quiz_input.cover_image == "":
|
||||||
quiz_input.cover_image = None
|
quiz_input.cover_image = None
|
||||||
|
|
||||||
|
# if quiz_input.background_image is None and old_quiz_data.background_image is not None:
|
||||||
|
# mark_image_for_deletion(quiz_input.background_image)
|
||||||
|
|
||||||
if quiz_input.cover_image is not None and not bool(re.match(server_regex, quiz_input.cover_image)):
|
if quiz_input.cover_image is not None and not bool(re.match(server_regex, quiz_input.cover_image)):
|
||||||
raise HTTPException(status_code=400, detail="image url is not valid")
|
raise HTTPException(status_code=400, detail="image url is not valid")
|
||||||
|
|
||||||
@@ -200,6 +203,7 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
|
|||||||
quiz.questions = quiz_input.dict()["questions"]
|
quiz.questions = quiz_input.dict()["questions"]
|
||||||
quiz.cover_image = quiz_input.cover_image
|
quiz.cover_image = quiz_input.cover_image
|
||||||
quiz.background_color = quiz_input.background_color
|
quiz.background_color = quiz_input.background_color
|
||||||
|
quiz.background_image = quiz_input.background_image
|
||||||
for image in images_to_delete:
|
for image in images_to_delete:
|
||||||
if image is not None:
|
if image is not None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ async def start_quiz(
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
background_color=quiz.background_color,
|
background_color=quiz.background_color,
|
||||||
custom_field=custom_field,
|
custom_field=custom_field,
|
||||||
|
background_image=quiz.background_image,
|
||||||
)
|
)
|
||||||
await redis.set(f"game:{str(game.game_pin)}", game.json(), ex=18000)
|
await redis.set(f"game:{str(game.game_pin)}", game.json(), ex=18000)
|
||||||
await redis.set(f"game_pin:{user.id}:{quiz_id}", game_pin, ex=18000)
|
await redis.set(f"game_pin:{user.id}:{quiz_id}", game_pin, ex=18000)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
const { t } = getLocalization();
|
const { t } = getLocalization();
|
||||||
|
|
||||||
let uppyOpen = false;
|
let uppyOpen = false;
|
||||||
|
let bg_uppy_open = false;
|
||||||
|
|
||||||
export let edit_id: string;
|
export let edit_id: string;
|
||||||
export let data: EditorData;
|
export let data: EditorData;
|
||||||
@@ -38,7 +39,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dark:bg-gray-700">
|
<div
|
||||||
|
class="dark:bg-gray-700 h-full"
|
||||||
|
style="background-repeat: no-repeat;background-size: 100% 100%;background-image: {data.background_image
|
||||||
|
? `url("${data.background_image}")`
|
||||||
|
: `unset`}"
|
||||||
|
>
|
||||||
<div class="flex justify-center pt-10 w-full">
|
<div class="flex justify-center pt-10 w-full">
|
||||||
<!--<input
|
<!--<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -173,6 +179,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-center pt-10">
|
||||||
|
<h3>Background-Image</h3>
|
||||||
|
</div>
|
||||||
|
<div class="w-full flex justify-center -mt-8">
|
||||||
|
{#if data.background_image}
|
||||||
|
<button
|
||||||
|
on:click={() => {
|
||||||
|
data.background_image = undefined;
|
||||||
|
}}
|
||||||
|
class="mt-10 bg-red-500 p-2 rounded-lg border-2 border-black transition hover:bg-red-400"
|
||||||
|
>Remove Background-Image</button
|
||||||
|
>
|
||||||
|
{:else if pow_data === undefined}
|
||||||
|
<a href="/docs/pow" target="_blank" class="cursor-help pt-10">
|
||||||
|
<Spinner my_20={false} />
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
{#await import('$lib/editor/uploader.svelte')}
|
||||||
|
<div class="pt-10">
|
||||||
|
<Spinner my_20={false} />
|
||||||
|
</div>
|
||||||
|
{:then c}
|
||||||
|
<svelte:component
|
||||||
|
this={c.default}
|
||||||
|
bind:modalOpen={bg_uppy_open}
|
||||||
|
bind:edit_id
|
||||||
|
bind:data
|
||||||
|
selected_question={-1}
|
||||||
|
bind:pow_data
|
||||||
|
bind:pow_salt
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import '@uppy/image-editor/dist/style.css';
|
import '@uppy/image-editor/dist/style.css';
|
||||||
import type { EditorData } from '../quiz_types';
|
import type { EditorData } from '../quiz_types';
|
||||||
import { getLocalization } from '$lib/i18n';
|
import { getLocalization } from '$lib/i18n';
|
||||||
|
|
||||||
const { t } = getLocalization();
|
const { t } = getLocalization();
|
||||||
|
|
||||||
export let modalOpen = false;
|
export let modalOpen = false;
|
||||||
@@ -65,6 +66,8 @@
|
|||||||
console.log(pow_data);
|
console.log(pow_data);
|
||||||
if (selected_question === undefined) {
|
if (selected_question === undefined) {
|
||||||
data.cover_image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
|
data.cover_image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
|
||||||
|
} else if (selected_question === -1) {
|
||||||
|
data.background_image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
|
||||||
} else {
|
} else {
|
||||||
data.questions[
|
data.questions[
|
||||||
selected_question
|
selected_question
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface QuizData {
|
|||||||
started: boolean;
|
started: boolean;
|
||||||
cover_image?: string;
|
cover_image?: string;
|
||||||
background_color?: string;
|
background_color?: string;
|
||||||
|
background_image?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QuizQuestionType {
|
export enum QuizQuestionType {
|
||||||
@@ -65,4 +66,5 @@ export interface EditorData {
|
|||||||
questions: Question[];
|
questions: Question[];
|
||||||
cover_image?: string;
|
cover_image?: string;
|
||||||
background_color?: string;
|
background_color?: string;
|
||||||
|
background_image?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,9 @@
|
|||||||
players = players;
|
players = players;
|
||||||
};
|
};
|
||||||
let bg_color;
|
let bg_color;
|
||||||
|
let bg_image;
|
||||||
$: bg_color = quiz_data ? quiz_data.background_color : undefined;
|
$: bg_color = quiz_data ? quiz_data.background_color : undefined;
|
||||||
|
$: bg_image = quiz_data ? quiz_data.background_image : undefined;
|
||||||
let show_final_results = false;
|
let show_final_results = false;
|
||||||
$: show_final_results = JSON.stringify(final_results) !== JSON.stringify([null]);
|
$: show_final_results = JSON.stringify(final_results) !== JSON.stringify([null]);
|
||||||
</script>
|
</script>
|
||||||
@@ -142,7 +144,9 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
<div
|
<div
|
||||||
class="min-h-screen min-w-full"
|
class="min-h-screen min-w-full"
|
||||||
style="background: {bg_color ? bg_color : 'transparent'}"
|
style="background-repeat: no-repeat;background-size: 100% 100%;background-image: {bg_image
|
||||||
|
? `url('${bg_image}')`
|
||||||
|
: `unset`}; background-color: {bg_color ? bg_color : 'transparent'}"
|
||||||
class:text-black={bg_color}
|
class:text-black={bg_color}
|
||||||
>
|
>
|
||||||
{#if JSON.stringify(final_results) !== JSON.stringify([null])}
|
{#if JSON.stringify(final_results) !== JSON.stringify([null])}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Added background_image
|
||||||
|
|
||||||
|
Revision ID: 820e06ef2c2a
|
||||||
|
Revises: 694cb11c6886
|
||||||
|
Create Date: 2022-12-28 18:27:57.035467
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import ormar
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "820e06ef2c2a"
|
||||||
|
down_revision = "694cb11c6886"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column("quiz", sa.Column("background_image", sa.Text(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column("quiz", "background_image")
|
||||||
|
# ### end Alembic commands ###
|
||||||
Reference in New Issue
Block a user