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 uuid
from datetime import datetime
from typing import Any
import ormar.exceptions
import xlsxwriter
from aiohttp import ClientSession
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from fastapi.responses import StreamingResponse
from classquiz.auth import get_current_user
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 urllib.parse
import magic
@@ -131,3 +133,60 @@ async def import_quiz(file: UploadFile = File(), user: User = Depends(get_curren
quiz.mod_rating = None
await quiz.save()
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!",
"how_does_classquiz_work": "How does ClassQuiz even work?",
"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": {
"created_at": "Created at",
@@ -193,7 +194,8 @@
"dislike": "Dislike",
"dislike_plural": "Dislikes",
"play_plural": "Plays",
"info": "Info"
"info": "Info",
"player": "Player"
},
"editor": {
"time_in_seconds": "Time in seconds",
@@ -254,7 +256,8 @@
"enter_answer_into_field": "Enter your answer into the input field!",
"answers_submitted": "{{answer_count}} Answers submitted",
"request_export_results": "Request result download",
"download_export_results": "Download results"
"download_export_results": "Download results",
"export_results": "Export results"
},
"password_reset_page": {
"reset_password": "Reset password"
@@ -292,7 +295,8 @@
"join_by_entering_code": "Join by entering the following code",
"points_added": "Points added",
"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": {
"add_an_answer": "Add an answer",
@@ -448,7 +452,8 @@
},
"public_user_page": {
"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": {
"not_available": "Not available",
@@ -469,5 +474,11 @@
"video_uploader": {
"time_elapsed": "Time elapsed",
"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">
import DownloadQuiz from '$lib/components/DownloadQuiz.svelte';
import type { QuizData } from '$lib/quiz_types';
import { getLocalization } from '$lib/i18n';
import Footer from '$lib/footer.svelte';
@@ -25,6 +26,7 @@ SPDX-License-Identifier: MPL-2.0
export let data: PageData;
let search_term = '';
let start_game = null;
let download_id: string | null = null;
signedIn.set(true);
navbarVisible.set(true);
const { t } = getLocalization();
@@ -360,7 +362,7 @@ SPDX-License-Identifier: MPL-2.0
</svg>
</BrownButton>
<BrownButton
href="/api/v1/eximport/{quiz.id}"
on:click={() => (download_id = quiz.id)}
flex={true}
disabled={quiz.type !== 'quiz'}
><!-- heroicons/download -->
@@ -397,3 +399,4 @@ SPDX-License-Identifier: MPL-2.0
{#if start_game !== null}
<StartGamePopup bind:quiz_id={start_game} />
{/if}
<DownloadQuiz bind:quiz_id={download_id} />
@@ -5,6 +5,7 @@ SPDX-License-Identifier: MPL-2.0
-->
<script lang="ts">
import DownloadQuiz from '$lib/components/DownloadQuiz.svelte';
import { getLocalization } from '$lib/i18n';
import CollapsSection from '$lib/collapsible.svelte';
import { createTippy } from 'svelte-tippy';
@@ -29,6 +30,7 @@ SPDX-License-Identifier: MPL-2.0
});
let start_game = null;
let download_id: string | null = null;
const urlparams = $page.url.searchParams;
const mod_view = Boolean(urlparams.get('mod'));
const auto_expand = Boolean(urlparams.get('autoExpand'));
@@ -186,7 +188,7 @@ SPDX-License-Identifier: MPL-2.0
</div>
<div class="w-full">
{#if logged_in}
<GrayButton flex={true} href="/api/v1/eximport/{quiz.id}">
<GrayButton flex={true} on:click={() => (download_id = quiz.id)}>
<svg
class="w-5 h-5 inline-block"
fill="none"
@@ -341,3 +343,5 @@ SPDX-License-Identifier: MPL-2.0
{#if start_game !== null}
<StartGamePopup bind:quiz_id={start_game} />
{/if}
<DownloadQuiz bind:quiz_id={download_id} />