Quizzes can now be exported as excel files

This commit is contained in:
Mawoka
2023-10-30 11:11:50 +01:00
parent 8691d6cde0
commit 31d1e3e3e6
5 changed files with 135 additions and 8 deletions
+60 -1
View File
@@ -7,14 +7,16 @@ import io
import json import json
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Any
import ormar.exceptions import ormar.exceptions
import xlsxwriter
from aiohttp import ClientSession from aiohttp import ClientSession
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from classquiz.auth import get_current_user from classquiz.auth import get_current_user
from classquiz.config import storage, settings, arq from classquiz.config import storage, settings, arq
from classquiz.db.models import Quiz, User, StorageItem from classquiz.db.models import Quiz, User, StorageItem, QuizQuestionType, QuizQuestion
import gzip import gzip
import urllib.parse import urllib.parse
import magic import magic
@@ -131,3 +133,60 @@ async def import_quiz(file: UploadFile = File(), user: User = Depends(get_curren
quiz.mod_rating = None quiz.mod_rating = None
await quiz.save() await quiz.save()
return quiz return quiz
@router.get("/excel/{quiz_id}")
async def export_quiz_as_excel(quiz_id: uuid.UUID, user: User = Depends(get_current_user)):
try:
quiz: Quiz = await Quiz.objects.filter(Quiz.id == quiz_id).first()
except ormar.exceptions.NoMatch:
raise HTTPException(status_code=404, detail="Quiz not found")
storage = io.BytesIO()
workbook = xlsxwriter.Workbook(storage, {"in_memory": True})
ws = workbook.add_worksheet()
ws.name = "Main"
ws.write(4, 1, "Title")
ws.write(4, 2, quiz.title)
ws.write(5, 1, "Description")
ws.write(5, 2, quiz.description)
ws.write_row(
12,
1,
[
"Question",
"1st Answer",
"2nd Answer",
"3rd Answer",
"4th Answer",
"Time Limit (max. 120)",
"Correct Answers",
],
)
for i, question in enumerate(quiz.questions):
question = QuizQuestion.parse_obj(question)
data: list[Any] = [None] * 9
data[0] = i + 1
data[1] = question.question
if question.type not in [QuizQuestionType.ABCD, QuizQuestionType.VOTING]:
continue
correct_answers = []
for a, answer in enumerate(question.answers):
data[2 + a] = answer.answer
if question.type == QuizQuestionType.ABCD and answer.right:
correct_answers.append(str(a + 1))
data[6] = question.time
data[7] = ",".join(correct_answers)
ws.write_row(13 + i, 0, data)
workbook.close()
storage.seek(0)
def iter_file():
yield from storage
return StreamingResponse(
iter_file(),
media_type="application/vnd.ms-excel",
headers={
"Content-Disposition": f"attachment;filename=ClassQuiz-{urllib.parse.quote(quiz.title)}.xlsx" # noqa: E501
},
)
@@ -0,0 +1,50 @@
<!--
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
SPDX-License-Identifier: MPL-2.0
-->
<script lang="ts">
import BrownButton from '$lib/components/buttons/brown.svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let quiz_id: string | null = null;
const handle_on_click = () => {
quiz_id = null;
};
onMount(() => {
window.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
quiz_id = null;
}
});
});
</script>
{#if quiz_id}
<div
class="w-screen h-screen fixed top-0 left-0 bg-opacity-50 bg-black z-20 flex justify-center"
on:click={handle_on_click}
transition:fade|local={{ duration: 100 }}
>
<div class="m-auto w-1/3 h-auto bg-white dark:bg-gray-700 p-4 rounded">
<h1 class="text-3xl text-center mb-4">{$t('downloader.select_download_type')}</h1>
<div class="flex flex-row gap-4">
<div class="w-full flex justify-center">
<BrownButton href="/api/v1/eximport/{quiz_id}"
>{$t('downloader.own_format')}
</BrownButton>
</div>
<div class="w-full flex justify-center">
<BrownButton href="/api/v1/eximport/excel/{quiz_id}"
>{$t('downloader.excel_format')}
</BrownButton>
</div>
</div>
<p class="mt-2 text-sm text-center">{$t('downloader.help')}</p>
</div>
</div>
{/if}
+16 -5
View File
@@ -45,7 +45,8 @@
"community_driven_content": "ClassQuiz depends on its community for funding, testing ideas, feature requests, translations and more! You can also be a part of the ClassQuiz-community!", "community_driven_content": "ClassQuiz depends on its community for funding, testing ideas, feature requests, translations and more! You can also be a part of the ClassQuiz-community!",
"how_does_classquiz_work": "How does ClassQuiz even work?", "how_does_classquiz_work": "How does ClassQuiz even work?",
"no_player_limit": "No player limit", "no_player_limit": "No player limit",
"no_player_limit_content": "ClassQuiz doesn't have an artificial player limit. In theory, 1000+ players could play the same quiz, but it's tested with up to 300 players per quiz." "no_player_limit_content": "ClassQuiz doesn't have an artificial player limit. In theory, 1000+ players could play the same quiz, but it's tested with up to 300 players per quiz.",
"import_quiz_from_kahoot_and_edit": ""
}, },
"overview_page": { "overview_page": {
"created_at": "Created at", "created_at": "Created at",
@@ -193,7 +194,8 @@
"dislike": "Dislike", "dislike": "Dislike",
"dislike_plural": "Dislikes", "dislike_plural": "Dislikes",
"play_plural": "Plays", "play_plural": "Plays",
"info": "Info" "info": "Info",
"player": "Player"
}, },
"editor": { "editor": {
"time_in_seconds": "Time in seconds", "time_in_seconds": "Time in seconds",
@@ -254,7 +256,8 @@
"enter_answer_into_field": "Enter your answer into the input field!", "enter_answer_into_field": "Enter your answer into the input field!",
"answers_submitted": "{{answer_count}} Answers submitted", "answers_submitted": "{{answer_count}} Answers submitted",
"request_export_results": "Request result download", "request_export_results": "Request result download",
"download_export_results": "Download results" "download_export_results": "Download results",
"export_results": "Export results"
}, },
"password_reset_page": { "password_reset_page": {
"reset_password": "Reset password" "reset_password": "Reset password"
@@ -292,7 +295,8 @@
"join_by_entering_code": "Join by entering the following code", "join_by_entering_code": "Join by entering the following code",
"points_added": "Points added", "points_added": "Points added",
"your_place": "You're on place {{place}}!", "your_place": "You're on place {{place}}!",
"players_waiting": "{{count}} players are waiting" "players_waiting": "{{count}} player are waiting",
"players_waiting_plural": "{{count}} players are waiting"
}, },
"editor_page": { "editor_page": {
"add_an_answer": "Add an answer", "add_an_answer": "Add an answer",
@@ -448,7 +452,8 @@
}, },
"public_user_page": { "public_user_page": {
"joined_on": "Joined on {{date}}", "joined_on": "Joined on {{date}}",
"no_original_quizzes": "This user doesn't have any original quizzes" "no_original_quizzes": "This user doesn't have any original quizzes",
"user_not_found": "User not found"
}, },
"file_dashboard": { "file_dashboard": {
"not_available": "Not available", "not_available": "Not available",
@@ -469,5 +474,11 @@
"video_uploader": { "video_uploader": {
"time_elapsed": "Time elapsed", "time_elapsed": "Time elapsed",
"time_remaining": "Time remaining" "time_remaining": "Time remaining"
},
"downloader": {
"select_download_type": "Select download type",
"own_format": "Own Format",
"excel_format": "Excel Format",
"help": "The own format (.cqa) also includes pictures and supports every question type, whereas the excel format only supports voting and multiple-choice questions."
} }
} }
+4 -1
View File
@@ -5,6 +5,7 @@ SPDX-License-Identifier: MPL-2.0
--> -->
<script lang="ts"> <script lang="ts">
import DownloadQuiz from '$lib/components/DownloadQuiz.svelte';
import type { QuizData } from '$lib/quiz_types'; import type { QuizData } from '$lib/quiz_types';
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import Footer from '$lib/footer.svelte'; import Footer from '$lib/footer.svelte';
@@ -25,6 +26,7 @@ SPDX-License-Identifier: MPL-2.0
export let data: PageData; export let data: PageData;
let search_term = ''; let search_term = '';
let start_game = null; let start_game = null;
let download_id: string | null = null;
signedIn.set(true); signedIn.set(true);
navbarVisible.set(true); navbarVisible.set(true);
const { t } = getLocalization(); const { t } = getLocalization();
@@ -360,7 +362,7 @@ SPDX-License-Identifier: MPL-2.0
</svg> </svg>
</BrownButton> </BrownButton>
<BrownButton <BrownButton
href="/api/v1/eximport/{quiz.id}" on:click={() => (download_id = quiz.id)}
flex={true} flex={true}
disabled={quiz.type !== 'quiz'} disabled={quiz.type !== 'quiz'}
><!-- heroicons/download --> ><!-- heroicons/download -->
@@ -397,3 +399,4 @@ SPDX-License-Identifier: MPL-2.0
{#if start_game !== null} {#if start_game !== null}
<StartGamePopup bind:quiz_id={start_game} /> <StartGamePopup bind:quiz_id={start_game} />
{/if} {/if}
<DownloadQuiz bind:quiz_id={download_id} />
@@ -5,6 +5,7 @@ SPDX-License-Identifier: MPL-2.0
--> -->
<script lang="ts"> <script lang="ts">
import DownloadQuiz from '$lib/components/DownloadQuiz.svelte';
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import CollapsSection from '$lib/collapsible.svelte'; import CollapsSection from '$lib/collapsible.svelte';
import { createTippy } from 'svelte-tippy'; import { createTippy } from 'svelte-tippy';
@@ -29,6 +30,7 @@ SPDX-License-Identifier: MPL-2.0
}); });
let start_game = null; let start_game = null;
let download_id: string | null = null;
const urlparams = $page.url.searchParams; const urlparams = $page.url.searchParams;
const mod_view = Boolean(urlparams.get('mod')); const mod_view = Boolean(urlparams.get('mod'));
const auto_expand = Boolean(urlparams.get('autoExpand')); const auto_expand = Boolean(urlparams.get('autoExpand'));
@@ -186,7 +188,7 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
<div class="w-full"> <div class="w-full">
{#if logged_in} {#if logged_in}
<GrayButton flex={true} href="/api/v1/eximport/{quiz.id}"> <GrayButton flex={true} on:click={() => (download_id = quiz.id)}>
<svg <svg
class="w-5 h-5 inline-block" class="w-5 h-5 inline-block"
fill="none" fill="none"
@@ -341,3 +343,5 @@ SPDX-License-Identifier: MPL-2.0
{#if start_game !== null} {#if start_game !== null}
<StartGamePopup bind:quiz_id={start_game} /> <StartGamePopup bind:quiz_id={start_game} />
{/if} {/if}
<DownloadQuiz bind:quiz_id={download_id} />