Finished new editor and deprecated old endpoints

This commit is contained in:
Mawoka
2022-06-17 12:12:22 +02:00
parent 6d5fac39fd
commit f39e2bee89
10 changed files with 207 additions and 135 deletions
+50 -3
View File
@@ -1,8 +1,10 @@
import asyncio import asyncio
import html import html
import re
import uuid import uuid
from typing import Optional from typing import Optional
import asyncpg.exceptions
import bleach import bleach
import pydantic import pydantic
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, BackgroundTasks, Depends from fastapi import APIRouter, File, Form, UploadFile, HTTPException, BackgroundTasks, Depends
@@ -16,6 +18,9 @@ from datetime import datetime
from uuid import UUID from uuid import UUID
from classquiz.helpers import get_meili_data from classquiz.helpers import get_meili_data
from classquiz.storage.errors import DeletionFailedError
settings = settings()
router = APIRouter() router = APIRouter()
@@ -83,8 +88,41 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
session_data = EditSessionData.parse_raw(session_data) session_data = EditSessionData.parse_raw(session_data)
quiz_input.title = html.unescape(bleach.clean(quiz_input.title, tags=[], strip=True)) 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)) 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: 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)]) meilisearch.index(settings.meilisearch_index).update_documents([await get_meili_data(quiz)])
if quiz.public and not quiz_input.public: if quiz.public and not quiz_input.public:
meilisearch.index(settings.meilisearch_index).delete_document(str(quiz.id)) 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.description = quiz_input.description
quiz.updated_at = datetime.now() quiz.updated_at = datetime.now()
quiz.questions = quiz_input.dict()["questions"] 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: else:
quiz = Quiz(**quiz_input.dict(), user_id=session_data.user_id, id=session_data.quiz_id) quiz = Quiz(**quiz_input.dict(), user_id=session_data.user_id, id=session_data.quiz_id)
await redis.delete("global_quiz_count") await redis.delete("global_quiz_count")
if quiz_input.public: if quiz_input.public:
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz)]) 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")
+4 -3
View File
@@ -1,4 +1,5 @@
from io import BytesIO from io import BytesIO
from classquiz.storage.errors import DeletionFailedError, SavingFailedError, DownloadingFailedError
from aiohttp import ClientSession from aiohttp import ClientSession
@@ -26,7 +27,7 @@ class DetaStorage:
elif response.status == 404: elif response.status == 404:
return None return None
else: else:
raise Exception("Download failed") raise DownloadingFailedError
async def upload(self, file: bytes, file_name: str) -> None: async def upload(self, file: bytes, file_name: str) -> None:
""" """
@@ -40,7 +41,7 @@ class DetaStorage:
if response.status == 201: if response.status == 201:
return None return None
else: else:
raise Exception("Upload failed") raise SavingFailedError
async def delete(self, file_names: [str]) -> None: async def delete(self, file_names: [str]) -> None:
async with ClientSession(headers=self.headers) as session, session.delete( async with ClientSession(headers=self.headers) as session, session.delete(
@@ -49,4 +50,4 @@ class DetaStorage:
if response.status == 200: if response.status == 200:
return None return None
else: else:
raise Exception("Delete failed") raise DeletionFailedError
+10
View File
@@ -0,0 +1,10 @@
class DeletionFailedError(Exception):
pass
class SavingFailedError(Exception):
pass
class DownloadingFailedError(Exception):
pass
+78 -41
View File
@@ -66,6 +66,7 @@
answer: '' answer: ''
}; };
let edit_id; let edit_id;
let confirm_to_leave = true;
const getEditID = async () => { const getEditID = async () => {
let res; let res;
@@ -86,54 +87,90 @@
alert('Error!'); 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> </script>
<svelte:window on:beforeunload={confirmUnload} />
{#await getEditID()} {#await getEditID()}
<Spinner /> <Spinner />
{:then _} {:then _}
<div class="grid grid-cols-6 h-screen w-screen"> <form on:submit|preventDefault={saveQuiz}>
<div> <div class="grid grid-cols-6 h-screen w-screen">
<Sidebar bind:data bind:selected_question /> <div>
</div> <Sidebar bind:data bind:selected_question />
<div class="col-span-5 flex flex-col"> </div>
<div class="h-10 w-full bg-white mb-10 flex align-middle justify-center rounded-br-lg"> <div class="col-span-5 flex flex-col">
{#if schemaInvalid} <div
<p class="text-center w-full text-red-600 h-full mt-0.5 font-semibold"> class="h-10 w-full bg-white mb-10 flex align-middle justify-center rounded-br-lg"
{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}
> >
<span>Save</span> {#if schemaInvalid}
<svg <p class="text-center w-full text-red-600 h-full mt-0.5 font-semibold">
class="w-6 h-6 inline-block" {yupErrorMessage}
fill="none" </p>
stroke="currentColor" {:else}
viewBox="0 0 24 24" <p class="text-center w-full text-black h-full align-bottom mt-0.5">
xmlns="http://www.w3.org/2000/svg" {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 <span>Save</span>
stroke-linecap="round" <svg
stroke-linejoin="round" class="w-6 h-6 inline-block"
stroke-width="2" fill="none"
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" stroke="currentColor"
/> viewBox="0 0 24 24"
</svg> xmlns="http://www.w3.org/2000/svg"
</button> >
</div> <path
<div class="w-full h-full"> stroke-linecap="round"
{#if selected_question === -1} stroke-linejoin="round"
<SettingsCard bind:data /> stroke-width="2"
{:else} 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"
<QuizCard bind:data bind:selected_question bind:edit_id /> />
{/if} </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> </div>
</div> </form>
{/await} {/await}
+14 -6
View File
@@ -4,6 +4,7 @@
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 { fade } from 'svelte/transition';
export let data: EditorData; export let data: EditorData;
export let selected_question: number; export let selected_question: number;
@@ -49,7 +50,14 @@
</div> </div>
{#if question.image != undefined && question.image !== ''} {#if question.image != undefined && question.image !== ''}
<div class="flex justify-center pt-10 w-full max-h-72 w-full"> <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> </div>
{:else} {:else}
{#await import('$lib/editor/uploader.svelte')} {#await import('$lib/editor/uploader.svelte')}
@@ -60,6 +68,7 @@
bind:modalOpen={uppyOpen} bind:modalOpen={uppyOpen}
bind:edit_id bind:edit_id
bind:data bind:data
bind:selected_question
/> />
{/await} {/await}
{/if} {/if}
@@ -71,7 +80,8 @@
question.answers.splice(index, 1); question.answers.splice(index, 1);
question.answers = question.answers; 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-red-500={!answer.right}
class:bg-green-500={answer.right} class:bg-green-500={answer.right}
class:bg-yellow-500={!reach( class:bg-yellow-500={!reach(
@@ -127,8 +137,9 @@
{/each} {/each}
{#if question.answers.length < 4} {#if question.answers.length < 4}
<button <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" type="button"
in:fade={{ duration: 150 }}
on:click={() => { on:click={() => {
question.answers = [...question.answers, { empty_answer }]; question.answers = [...question.answers, { empty_answer }];
}} }}
@@ -142,6 +153,3 @@
</div> </div>
</div> </div>
</div> </div>
{#if uppyOpen}
<span class="fixed w-screen h-screen bg-opacity-60 z-10">1</span>
{/if}
+2 -2
View File
@@ -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" class="p-3 rounded-lg border-gray-500 border text-center w-1/3 h-20 resize-none dark:bg-gray-500"
/> />
</div> </div>
<div class="pt-10"> <div class="pt-10 w-full flex justify-center">
<button <button
type="button" type="button"
on:click={() => { on:click={() => {
data.public = !data.public; data.public = !data.public;
}} }}
class="text-center w-full" class="text-center w-fit"
> >
{#if data.public} {#if data.public}
<svg <svg
+15 -7
View File
@@ -60,7 +60,8 @@
> >
<input <input
type="text" 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} bind:value={data.title}
/> />
</div> </div>
@@ -73,10 +74,14 @@
> >
<textarea <textarea
bind:value={data.description} 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>
<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 <button
type="button" type="button"
on:click={() => { on:click={() => {
@@ -126,8 +131,10 @@
class:bg-green-300={index === selected_question} class:bg-green-300={index === selected_question}
class:dark:bg-green-500={index === selected_question} class:dark:bg-green-500={index === selected_question}
on:contextmenu|preventDefault={() => { on:contextmenu|preventDefault={() => {
data.questions.splice(index, 1); if (confirm('Do you really want to delete this Question?')) {
data.questions = data.questions; data.questions.splice(index, 1);
data.questions = data.questions;
}
}} }}
on:click={() => { on:click={() => {
setSelectedQuestion(index); setSelectedQuestion(index);
@@ -139,10 +146,11 @@
class="m-1 border border-gray-500 rounded-lg p-0.5" class="m-1 border border-gray-500 rounded-lg p-0.5"
> >
<h1 <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( class:bg-yellow-500={!reach(dataSchema, 'questions[].question').isValidSync(
question.question question.question
)} )}
class:dark:text-black={index === selected_question}
> >
{#if question.question === ''} {#if question.question === ''}
<span class="italic text-gray-500">No title...</span> <span class="italic text-gray-500">No title...</span>
@@ -152,7 +160,7 @@
</h1> </h1>
</div> </div>
{#if question.image} {#if question.image}
<div class="flex justify-center align-middle"> <div class="flex justify-center align-middle pb-0.5">
<img <img
src={question.image} src={question.image}
class="h-10 border rounded-lg" class="h-10 border rounded-lg"
+32 -8
View File
@@ -6,6 +6,7 @@
import ImageEditor from '@uppy/image-editor'; import ImageEditor from '@uppy/image-editor';
import Dashboard from '@uppy/dashboard'; import Dashboard from '@uppy/dashboard';
import Compressor from '@uppy/compressor'; import Compressor from '@uppy/compressor';
import { fade } from 'svelte/transition';
// CSS imports // CSS imports
import '@uppy/core/dist/style.css'; import '@uppy/core/dist/style.css';
@@ -43,30 +44,53 @@
uppy.on('complete', (res) => { uppy.on('complete', (res) => {
data.questions[ data.questions[
selected_question 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; modalOpen = false;
}); });
console.log(edit_id); console.log(edit_id);
</script> </script>
{#if modalOpen} {#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> <div>
<button <button
type="button" type="button"
class="rounded-t-lg bg-black text-white px-1"
on:click={() => { on:click={() => {
modalOpen = false; modalOpen = false;
}}>Close</button }}
> >Close
</button>
<div> <div>
<SvelteDashboard {uppy} width="100%" {props} /> <SvelteDashboard {uppy} width="100%" {props} />
</div> </div>
</div> </div>
</div> </div>
{:else} {/if}
<div class="flex justify-center w-full pt-10" transition:fade>
<button <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={() => { on:click={() => {
modalOpen = true; modalOpen = true;
}}>Add Image</button }}
> ><span class="italic">Add Image</span>
{/if} <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>
+1 -32
View File
@@ -64,45 +64,14 @@
data = JSON.parse(from_localstorage); 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> </script>
<svelte:window on:beforeunload={confirmUnload} />
<svelte:head> <svelte:head>
<title>ClassQuiz - Create</title> <title>ClassQuiz - Create</title>
</svelte:head> </svelte:head>
{#if data !== undefined} {#if data !== undefined}
<form on:submit|preventDefault={submit} class="grid grid-cols-1 gap-2"> <Editor bind:data bind:quiz_id />
<Editor bind:data bind:quiz_id />
</form>
{/if} {/if}
<div <div
+1 -33
View File
@@ -73,38 +73,8 @@
return; 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> </script>
<svelte:window on:beforeunload={confirmUnload} />
<svelte:head> <svelte:head>
<title>ClassQuiz - Edit</title> <title>ClassQuiz - Edit</title>
</svelte:head> </svelte:head>
@@ -121,9 +91,7 @@
</svg> </svg>
{:then _} {:then _}
{#if data !== undefined} {#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 />
<Editor bind:data submit_button_text={$t('words.save')} bind:quiz_id />
</form>
{/if} {/if}
{:catch err} {:catch err}
<div class="text-center"> <div class="text-center">