✨ Finished new editor and deprecated old endpoints
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import html
|
||||
import re
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import asyncpg.exceptions
|
||||
import bleach
|
||||
import pydantic
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, BackgroundTasks, Depends
|
||||
@@ -16,6 +18,9 @@ from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from classquiz.helpers import get_meili_data
|
||||
from classquiz.storage.errors import DeletionFailedError
|
||||
|
||||
settings = settings()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -83,8 +88,41 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
|
||||
session_data = EditSessionData.parse_raw(session_data)
|
||||
quiz_input.title = html.unescape(bleach.clean(quiz_input.title, tags=[], strip=True))
|
||||
quiz_input.description = html.unescape(bleach.clean(quiz_input.description, tags=[], strip=True))
|
||||
image_id_regex = r"^.{36}--.{36}$"
|
||||
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})$"
|
||||
images_to_delete = []
|
||||
old_quiz_data = 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):
|
||||
if old_quiz is None:
|
||||
return
|
||||
print(new, old_quiz.questions[index]["image"])
|
||||
if new == old_quiz.questions[index]["image"]:
|
||||
return
|
||||
else:
|
||||
images_to_delete.append(old_quiz.questions[index]["image"])
|
||||
|
||||
for i, question in enumerate(quiz_input.questions):
|
||||
image = question.image
|
||||
if image == "":
|
||||
question.image = None
|
||||
mark_image_for_deletion(question.image, i, old_quiz_data)
|
||||
elif image is None:
|
||||
mark_image_for_deletion(question.image, i, old_quiz_data)
|
||||
elif bool(re.match(image_id_regex, question.image)):
|
||||
question.image = f"{settings.root_address}/api/v1/storage/download/{image}"
|
||||
mark_image_for_deletion(question.image, i, old_quiz_data)
|
||||
elif bool(re.match(imgur_regex, image)):
|
||||
mark_image_for_deletion(question.image, i, old_quiz_data)
|
||||
elif bool(re.match(server_regex, image)):
|
||||
mark_image_for_deletion(question.image, i, old_quiz_data)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Image URL(s) aren't valid!")
|
||||
print(images_to_delete)
|
||||
if session_data.edit:
|
||||
quiz = await Quiz.objects.get_or_none(id=session_data.quiz_id, user_id=session_data.user_id)
|
||||
quiz = old_quiz_data
|
||||
meilisearch.index(settings.meilisearch_index).update_documents([await get_meili_data(quiz)])
|
||||
if quiz.public and not quiz_input.public:
|
||||
meilisearch.index(settings.meilisearch_index).delete_document(str(quiz.id))
|
||||
@@ -95,10 +133,19 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
|
||||
quiz.description = quiz_input.description
|
||||
quiz.updated_at = datetime.now()
|
||||
quiz.questions = quiz_input.dict()["questions"]
|
||||
return await quiz.save()
|
||||
for image in images_to_delete:
|
||||
if image is not None:
|
||||
try:
|
||||
await storage.delete(re.search(extract_file_name_re, image).group(1))
|
||||
except DeletionFailedError:
|
||||
pass
|
||||
return await quiz.update()
|
||||
else:
|
||||
quiz = Quiz(**quiz_input.dict(), user_id=session_data.user_id, id=session_data.quiz_id)
|
||||
await redis.delete("global_quiz_count")
|
||||
if quiz_input.public:
|
||||
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz)])
|
||||
return await quiz.save()
|
||||
try:
|
||||
return await quiz.save()
|
||||
except asyncpg.exceptions.UniqueViolationError:
|
||||
raise HTTPException(status_code=400, detail="The quiz already exists")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from io import BytesIO
|
||||
from classquiz.storage.errors import DeletionFailedError, SavingFailedError, DownloadingFailedError
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
@@ -26,7 +27,7 @@ class DetaStorage:
|
||||
elif response.status == 404:
|
||||
return None
|
||||
else:
|
||||
raise Exception("Download failed")
|
||||
raise DownloadingFailedError
|
||||
|
||||
async def upload(self, file: bytes, file_name: str) -> None:
|
||||
"""
|
||||
@@ -40,7 +41,7 @@ class DetaStorage:
|
||||
if response.status == 201:
|
||||
return None
|
||||
else:
|
||||
raise Exception("Upload failed")
|
||||
raise SavingFailedError
|
||||
|
||||
async def delete(self, file_names: [str]) -> None:
|
||||
async with ClientSession(headers=self.headers) as session, session.delete(
|
||||
@@ -49,4 +50,4 @@ class DetaStorage:
|
||||
if response.status == 200:
|
||||
return None
|
||||
else:
|
||||
raise Exception("Delete failed")
|
||||
raise DeletionFailedError
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
class DeletionFailedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SavingFailedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DownloadingFailedError(Exception):
|
||||
pass
|
||||
@@ -66,6 +66,7 @@
|
||||
answer: ''
|
||||
};
|
||||
let edit_id;
|
||||
let confirm_to_leave = true;
|
||||
|
||||
const getEditID = async () => {
|
||||
let res;
|
||||
@@ -86,54 +87,90 @@
|
||||
alert('Error!');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmUnload = (event) => {
|
||||
console.log(confirm_to_leave);
|
||||
if (!confirm_to_leave) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.returnValue = 'Are you sure you want to leave?';
|
||||
localStorage.setItem('edit_game', JSON.stringify(data));
|
||||
return 'unload';
|
||||
};
|
||||
const saveQuiz = async () => {
|
||||
if (schemaInvalid) {
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/v1/editor/finish?edit_id=${edit_id}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log('Hier');
|
||||
confirm_to_leave = false;
|
||||
console.log(confirm_to_leave);
|
||||
window.location.href = '/overview';
|
||||
} else {
|
||||
alert('Error');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window on:beforeunload={confirmUnload} />
|
||||
{#await getEditID()}
|
||||
<Spinner />
|
||||
{:then _}
|
||||
<div class="grid grid-cols-6 h-screen w-screen">
|
||||
<div>
|
||||
<Sidebar bind:data bind:selected_question />
|
||||
</div>
|
||||
<div class="col-span-5 flex flex-col">
|
||||
<div class="h-10 w-full bg-white mb-10 flex align-middle justify-center rounded-br-lg">
|
||||
{#if schemaInvalid}
|
||||
<p class="text-center w-full text-red-600 h-full mt-0.5 font-semibold">
|
||||
{yupErrorMessage}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-center w-full text-black h-full align-bottom mt-0.5">
|
||||
{data.title}
|
||||
</p>
|
||||
{/if}
|
||||
<button
|
||||
class="pr-2 align-middle bg-purple-400 pl-2 ml-auto whitespace-nowrap disabled:opacity-60 rounded-br-lg"
|
||||
disabled={schemaInvalid}
|
||||
<form on:submit|preventDefault={saveQuiz}>
|
||||
<div class="grid grid-cols-6 h-screen w-screen">
|
||||
<div>
|
||||
<Sidebar bind:data bind:selected_question />
|
||||
</div>
|
||||
<div class="col-span-5 flex flex-col">
|
||||
<div
|
||||
class="h-10 w-full bg-white mb-10 flex align-middle justify-center rounded-br-lg"
|
||||
>
|
||||
<span>Save</span>
|
||||
<svg
|
||||
class="w-6 h-6 inline-block"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{#if schemaInvalid}
|
||||
<p class="text-center w-full text-red-600 h-full mt-0.5 font-semibold">
|
||||
{yupErrorMessage}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-center w-full text-black h-full align-bottom mt-0.5">
|
||||
{data.title}
|
||||
</p>
|
||||
{/if}
|
||||
<button
|
||||
class="pr-2 align-middle bg-purple-400 pl-2 ml-auto whitespace-nowrap disabled:opacity-60 rounded-br-lg"
|
||||
disabled={schemaInvalid}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full h-full">
|
||||
{#if selected_question === -1}
|
||||
<SettingsCard bind:data />
|
||||
{:else}
|
||||
<QuizCard bind:data bind:selected_question bind:edit_id />
|
||||
{/if}
|
||||
<span>Save</span>
|
||||
<svg
|
||||
class="w-6 h-6 inline-block"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full h-full">
|
||||
{#if selected_question === -1}
|
||||
<SettingsCard bind:data />
|
||||
{:else}
|
||||
<QuizCard bind:data bind:selected_question bind:edit_id />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/await}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { reach } from 'yup';
|
||||
import { dataSchema } from '$lib/yupSchemas';
|
||||
import Spinner from '../Spinner.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
export let data: EditorData;
|
||||
export let selected_question: number;
|
||||
@@ -49,7 +50,14 @@
|
||||
</div>
|
||||
{#if question.image != undefined && question.image !== ''}
|
||||
<div class="flex justify-center pt-10 w-full max-h-72 w-full">
|
||||
<img src={question.image} alt="not available" class="max-h-72 h-auto w-auto" />
|
||||
<img
|
||||
src={question.image}
|
||||
alt="not available"
|
||||
class="max-h-72 h-auto w-auto"
|
||||
on:contextmenu|preventDefault={() => {
|
||||
question.image = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{#await import('$lib/editor/uploader.svelte')}
|
||||
@@ -60,6 +68,7 @@
|
||||
bind:modalOpen={uppyOpen}
|
||||
bind:edit_id
|
||||
bind:data
|
||||
bind:selected_question
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
@@ -71,7 +80,8 @@
|
||||
question.answers.splice(index, 1);
|
||||
question.answers = question.answers;
|
||||
}}
|
||||
class="p-4 rounded-lg flex justify-center w-full"
|
||||
out:fade={{ duration: 150 }}
|
||||
class="p-4 rounded-lg flex justify-center w-full transition"
|
||||
class:bg-red-500={!answer.right}
|
||||
class:bg-green-500={answer.right}
|
||||
class:bg-yellow-500={!reach(
|
||||
@@ -127,8 +137,9 @@
|
||||
{/each}
|
||||
{#if question.answers.length < 4}
|
||||
<button
|
||||
class="p-4 rounded-lg bg-transparent border-gray-500 border-2"
|
||||
class="p-4 rounded-lg bg-transparent border-gray-500 border-2 hover:bg-gray-300 transition dark:hover:bg-gray-600"
|
||||
type="button"
|
||||
in:fade={{ duration: 150 }}
|
||||
on:click={() => {
|
||||
question.answers = [...question.answers, { empty_answer }];
|
||||
}}
|
||||
@@ -142,6 +153,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if uppyOpen}
|
||||
<span class="fixed w-screen h-screen bg-opacity-60 z-10">1</span>
|
||||
{/if}
|
||||
|
||||
@@ -34,13 +34,13 @@
|
||||
class="p-3 rounded-lg border-gray-500 border text-center w-1/3 h-20 resize-none dark:bg-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="pt-10">
|
||||
<div class="pt-10 w-full flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
data.public = !data.public;
|
||||
}}
|
||||
class="text-center w-full"
|
||||
class="text-center w-fit"
|
||||
>
|
||||
{#if data.public}
|
||||
<svg
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="whitespace-nowrap truncate text-center w-full bg-transparent rounded font-semibold dark:text-black"
|
||||
class="whitespace-nowrap truncate text-center w-full bg-transparent rounded font-semibold dark:text-white"
|
||||
class:dark:text-black={selected_question === -1}
|
||||
bind:value={data.title}
|
||||
/>
|
||||
</div>
|
||||
@@ -73,10 +74,14 @@
|
||||
>
|
||||
<textarea
|
||||
bind:value={data.description}
|
||||
class="bg-transparent resize-none w-full rounded text-sm dark:text-black"
|
||||
class="bg-transparent resize-none w-full rounded text-sm dark:text-white"
|
||||
class:dark:text-black={selected_question === -1}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full flex justify-center dark:text-black">
|
||||
<div
|
||||
class="w-full flex justify-center dark:text-white"
|
||||
class:dark:text-black={selected_question === -1}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
@@ -126,8 +131,10 @@
|
||||
class:bg-green-300={index === selected_question}
|
||||
class:dark:bg-green-500={index === selected_question}
|
||||
on:contextmenu|preventDefault={() => {
|
||||
data.questions.splice(index, 1);
|
||||
data.questions = data.questions;
|
||||
if (confirm('Do you really want to delete this Question?')) {
|
||||
data.questions.splice(index, 1);
|
||||
data.questions = data.questions;
|
||||
}
|
||||
}}
|
||||
on:click={() => {
|
||||
setSelectedQuestion(index);
|
||||
@@ -139,10 +146,11 @@
|
||||
class="m-1 border border-gray-500 rounded-lg p-0.5"
|
||||
>
|
||||
<h1
|
||||
class="whitespace-nowrap truncate text-center rounded-lg dark:text-black"
|
||||
class="whitespace-nowrap truncate text-center rounded-lg dark:text-white transition"
|
||||
class:bg-yellow-500={!reach(dataSchema, 'questions[].question').isValidSync(
|
||||
question.question
|
||||
)}
|
||||
class:dark:text-black={index === selected_question}
|
||||
>
|
||||
{#if question.question === ''}
|
||||
<span class="italic text-gray-500">No title...</span>
|
||||
@@ -152,7 +160,7 @@
|
||||
</h1>
|
||||
</div>
|
||||
{#if question.image}
|
||||
<div class="flex justify-center align-middle">
|
||||
<div class="flex justify-center align-middle pb-0.5">
|
||||
<img
|
||||
src={question.image}
|
||||
class="h-10 border rounded-lg"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import ImageEditor from '@uppy/image-editor';
|
||||
import Dashboard from '@uppy/dashboard';
|
||||
import Compressor from '@uppy/compressor';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
// CSS imports
|
||||
import '@uppy/core/dist/style.css';
|
||||
@@ -43,30 +44,53 @@
|
||||
uppy.on('complete', (res) => {
|
||||
data.questions[
|
||||
selected_question
|
||||
].image = `https://${window.location.hostname}/api/v1/storage/download/${image_id}`;
|
||||
].image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
|
||||
modalOpen = false;
|
||||
});
|
||||
console.log(edit_id);
|
||||
</script>
|
||||
|
||||
{#if modalOpen}
|
||||
<div class="w-full h-full absolute top-0 left-0 bg-opacity-60 z-20 flex justify-center">
|
||||
<div
|
||||
class="w-full h-full absolute top-0 left-0 bg-opacity-60 z-20 flex justify-center"
|
||||
transition:fade
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-t-lg bg-black text-white px-1"
|
||||
on:click={() => {
|
||||
modalOpen = false;
|
||||
}}>Close</button
|
||||
>
|
||||
}}
|
||||
>Close
|
||||
</button>
|
||||
<div>
|
||||
<SvelteDashboard {uppy} width="100%" {props} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{/if}
|
||||
<div class="flex justify-center w-full pt-10" transition:fade>
|
||||
<button
|
||||
class="rounded-lg p-4 flex justify-center bg-transparent border-gray-500 border-2 w-1/2 hover:bg-gray-300 dark:hover:bg-gray-600 transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
modalOpen = true;
|
||||
}}>Add Image</button
|
||||
>
|
||||
{/if}
|
||||
}}
|
||||
><span class="italic">Add Image</span>
|
||||
<svg
|
||||
class="w-6 h-6 inline-block"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -64,45 +64,14 @@
|
||||
data = JSON.parse(from_localstorage);
|
||||
}
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (!(await dataSchema.isValid(data))) {
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/api/v1/quiz/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (res.status === 401) {
|
||||
localStorage.setItem('create_game', JSON.stringify(data));
|
||||
window.location.href = '/account/login';
|
||||
} else if (res.status === 200) {
|
||||
localStorage.removeItem('create_game');
|
||||
responseData.open = true;
|
||||
}
|
||||
};
|
||||
const confirmUnload = () => {
|
||||
if (!confirm_to_leave) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
localStorage.setItem('create_game', JSON.stringify(data));
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window on:beforeunload={confirmUnload} />
|
||||
<svelte:head>
|
||||
<title>ClassQuiz - Create</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if data !== undefined}
|
||||
<form on:submit|preventDefault={submit} class="grid grid-cols-1 gap-2">
|
||||
<Editor bind:data bind:quiz_id />
|
||||
</form>
|
||||
<Editor bind:data bind:quiz_id />
|
||||
{/if}
|
||||
|
||||
<div
|
||||
|
||||
@@ -73,38 +73,8 @@
|
||||
return;
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
if (!(await dataSchema.isValid(data))) {
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/v1/quiz/update/${quiz_id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (res.status === 401) {
|
||||
throw new Error('Unauthorized');
|
||||
} else if (res.status === 404) {
|
||||
throw new Error('Quiz not found');
|
||||
} else if (res.status === 200) {
|
||||
localStorage.removeItem('edit_game');
|
||||
responseData.data = '200';
|
||||
responseData.open = true;
|
||||
}
|
||||
};
|
||||
const confirmUnload = () => {
|
||||
if (!confirm_to_leave) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
localStorage.setItem('edit_game', JSON.stringify(data));
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window on:beforeunload={confirmUnload} />
|
||||
<svelte:head>
|
||||
<title>ClassQuiz - Edit</title>
|
||||
</svelte:head>
|
||||
@@ -121,9 +91,7 @@
|
||||
</svg>
|
||||
{:then _}
|
||||
{#if data !== undefined}
|
||||
<form on:submit|preventDefault={submit} class="grid grid-cols-1 gap-2">
|
||||
<Editor bind:data submit_button_text={$t('words.save')} bind:quiz_id />
|
||||
</form>
|
||||
<Editor bind:data submit_button_text={$t('words.save')} bind:quiz_id />
|
||||
{/if}
|
||||
{:catch err}
|
||||
<div class="text-center">
|
||||
|
||||
Reference in New Issue
Block a user