✨ Quizzes can now be exported as excel files
This commit is contained in:
@@ -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}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user