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