✨ Added Ratins, Plays and Views to Quizzes
This commit is contained in:
@@ -180,6 +180,10 @@ class Quiz(ormar.Model):
|
|||||||
background_color: str | None = ormar.Text(nullable=True, unique=False)
|
background_color: str | None = ormar.Text(nullable=True, unique=False)
|
||||||
background_image: str | None = ormar.Text(nullable=True, unique=False)
|
background_image: str | None = ormar.Text(nullable=True, unique=False)
|
||||||
kahoot_id: uuid.UUID | None = ormar.UUID(nullable=True, default=None)
|
kahoot_id: uuid.UUID | None = ormar.UUID(nullable=True, default=None)
|
||||||
|
likes: int = ormar.Integer(nullable=False, default=0, server_default="0")
|
||||||
|
dislikes: int = ormar.Integer(nullable=False, default=0, server_default="0")
|
||||||
|
plays: int = ormar.Integer(nullable=False, default=0, server_default="0")
|
||||||
|
views: int = ormar.Integer(nullable=False, default=0, server_default="0")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
tablename = "quiz"
|
tablename = "quiz"
|
||||||
@@ -472,3 +476,16 @@ class Controller(ormar.Model):
|
|||||||
tablename = "controller"
|
tablename = "controller"
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
database = database
|
database = database
|
||||||
|
|
||||||
|
|
||||||
|
class Rating(ormar.Model):
|
||||||
|
id: uuid.UUID = ormar.UUID(primary_key=True)
|
||||||
|
user: uuid.UUID | User = ormar.ForeignKey(User)
|
||||||
|
positive: bool = ormar.Boolean(nullable=False)
|
||||||
|
created_at: datetime = ormar.DateTime(nullable=False, server_default=func.now())
|
||||||
|
quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
tablename = "rating"
|
||||||
|
metadata = metadata
|
||||||
|
database = database
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from classquiz.db.models import User, Quiz
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from classquiz.auth import get_current_user
|
||||||
|
from classquiz.db.models import User, Quiz, Rating
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -35,3 +40,38 @@ async def get_quizzes_from_user(user_id: UUID, imported: bool | None = None):
|
|||||||
raise HTTPException(status_code=404, detail="no quizzes found")
|
raise HTTPException(status_code=404, detail="no quizzes found")
|
||||||
else:
|
else:
|
||||||
return quizzes
|
return quizzes
|
||||||
|
|
||||||
|
|
||||||
|
class RateQuizInputType(str, enum.Enum):
|
||||||
|
LIKE = "LIKE"
|
||||||
|
DISLIKE = "DISLIKE"
|
||||||
|
|
||||||
|
|
||||||
|
class RateQuizInput(BaseModel):
|
||||||
|
type: RateQuizInputType
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rate/{quiz_id}")
|
||||||
|
async def rate_quiz(data: RateQuizInput, quiz_id: uuid.UUID, user: User = Depends(get_current_user)):
|
||||||
|
quiz = await Quiz.objects.get_or_none(id=quiz_id, public=True)
|
||||||
|
if quiz is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||||
|
rating = await Rating.objects.get_or_none(quiz=quiz, user=user)
|
||||||
|
positive = True
|
||||||
|
if data.type == RateQuizInputType.DISLIKE:
|
||||||
|
positive = False
|
||||||
|
if rating is not None and rating.positive == positive:
|
||||||
|
raise HTTPException(status_code=409, detail="Rating already submitted")
|
||||||
|
elif rating.positive != positive:
|
||||||
|
await rating.delete()
|
||||||
|
if rating.positive:
|
||||||
|
quiz.likes -= 1
|
||||||
|
else:
|
||||||
|
quiz.dislikes -= 1
|
||||||
|
rating = Rating(id=uuid.uuid4(), user=user, positive=positive, quiz=quiz, created_at=datetime.now())
|
||||||
|
await rating.save()
|
||||||
|
if positive:
|
||||||
|
quiz.likes += 1
|
||||||
|
else:
|
||||||
|
quiz.dislikes += 1
|
||||||
|
await quiz.update()
|
||||||
|
|||||||
@@ -56,18 +56,20 @@ class PublicQuizResponseUser(BaseModel):
|
|||||||
class PublicQuizResponse(Quiz.get_pydantic()):
|
class PublicQuizResponse(Quiz.get_pydantic()):
|
||||||
user_id: PublicQuizResponseUser
|
user_id: PublicQuizResponseUser
|
||||||
questions: list[QuizQuestion]
|
questions: list[QuizQuestion]
|
||||||
|
likes: int
|
||||||
|
dislikes: int
|
||||||
|
views: int
|
||||||
|
plays: int
|
||||||
|
|
||||||
|
|
||||||
@router.get("/get/public/{quiz_id}")
|
@router.get("/get/public/{quiz_id}")
|
||||||
async def get_public_quiz(quiz_id: str):
|
async def get_public_quiz(quiz_id: uuid.UUID):
|
||||||
try:
|
|
||||||
quiz_id = uuid.UUID(quiz_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail="badly formed quiz id")
|
|
||||||
quiz = await Quiz.objects.select_related("user_id").get_or_none(id=quiz_id)
|
quiz = await Quiz.objects.select_related("user_id").get_or_none(id=quiz_id)
|
||||||
if quiz is None:
|
if quiz is None:
|
||||||
return JSONResponse(status_code=404, content={"detail": "quiz not found"})
|
return JSONResponse(status_code=404, content={"detail": "quiz not found"})
|
||||||
else:
|
else:
|
||||||
|
quiz.views += 1
|
||||||
|
await quiz.update()
|
||||||
return PublicQuizResponse(**quiz.dict())
|
return PublicQuizResponse(**quiz.dict())
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +91,8 @@ async def start_quiz(
|
|||||||
quiz = await Quiz.objects.get_or_none(id=quiz_id, public=True)
|
quiz = await Quiz.objects.get_or_none(id=quiz_id, public=True)
|
||||||
if quiz is None:
|
if quiz is None:
|
||||||
return JSONResponse(status_code=404, content={"detail": "quiz not found"})
|
return JSONResponse(status_code=404, content={"detail": "quiz not found"})
|
||||||
|
quiz.plays += 1
|
||||||
|
await quiz.update()
|
||||||
game_pin = randint(100000, 999999)
|
game_pin = randint(100000, 999999)
|
||||||
if custom_field == "":
|
if custom_field == "":
|
||||||
custom_field = None
|
custom_field = None
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
"screenshot_plural": "Screenshots",
|
"screenshot_plural": "Screenshots",
|
||||||
"browser": "Browser",
|
"browser": "Browser",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
|
"view_plural": "Views",
|
||||||
"correct": "Correct",
|
"correct": "Correct",
|
||||||
"result": "Result",
|
"result": "Result",
|
||||||
"result_plural": "Results",
|
"result_plural": "Results",
|
||||||
@@ -182,7 +183,15 @@
|
|||||||
"files_library": "Files Library",
|
"files_library": "Files Library",
|
||||||
"answer_plural": "Answers",
|
"answer_plural": "Answers",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "no"
|
"no": "no",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"rating": "Rating",
|
||||||
|
"like": "Like",
|
||||||
|
"like_plural": "Likes",
|
||||||
|
"dislike": "Dislike",
|
||||||
|
"dislike_plural": "Dislikes",
|
||||||
|
"play_plural": "Plays",
|
||||||
|
"info": "Info"
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"time_in_seconds": "Time in seconds",
|
"time_in_seconds": "Time in seconds",
|
||||||
@@ -281,7 +290,9 @@
|
|||||||
"right_click_to_delete": "Right-click on an answer to delete it!"
|
"right_click_to_delete": "Right-click on an answer to delete it!"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"search_for_own_quizzes": "Search for your own quizzes"
|
"search_for_own_quizzes": "Search for your own quizzes",
|
||||||
|
"views_n_plays": "Views & Plays",
|
||||||
|
"info_analytics": "The \"Plays\" only show how often the quiz was started (you included), whereas the \"Views\" count how often the \"View\"-page was visited, so it also counts bots (sorry for that)."
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"self_ads": "Made with ❤️ by {{mawoka_link}} and with the help of {{others_link}}.",
|
"self_ads": "Made with ❤️ by {{mawoka_link}} and with the help of {{others_link}}.",
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export interface QuizData {
|
|||||||
cover_image?: string;
|
cover_image?: string;
|
||||||
background_color?: string;
|
background_color?: string;
|
||||||
background_image?: string;
|
background_image?: string;
|
||||||
|
likes: number;
|
||||||
|
dislikes: number;
|
||||||
|
plays: number;
|
||||||
|
views: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QuizQuestionType {
|
export enum QuizQuestionType {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||||
|
|
||||||
|
SPDX-License-Identifier: MPL-2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export let hovering: boolean;
|
||||||
|
|
||||||
|
function enter() {
|
||||||
|
hovering = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leave() {
|
||||||
|
hovering = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div on:mouseenter={enter} on:mouseleave={leave}>
|
||||||
|
<slot {hovering} />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||||
|
|
||||||
|
SPDX-License-Identifier: MPL-2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { QuizData } from '$lib/quiz_types';
|
||||||
|
import Hoverable from '$lib/view_quiz/Hoverable.svelte';
|
||||||
|
import { createTippy } from 'svelte-tippy';
|
||||||
|
|
||||||
|
export let quiz: QuizData;
|
||||||
|
|
||||||
|
let FeedBackButtonsHovered = {
|
||||||
|
dislike: false,
|
||||||
|
like: false
|
||||||
|
};
|
||||||
|
const tippy = createTippy({
|
||||||
|
arrow: true,
|
||||||
|
animation: 'perspective-subtle',
|
||||||
|
placement: 'top'
|
||||||
|
});
|
||||||
|
|
||||||
|
const complete_action = async (positive: boolean) => {
|
||||||
|
const res = await fetch(`/api/v1/community/rate/${quiz.id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type: positive ? 'LIKE' : 'DISLIKE' })
|
||||||
|
});
|
||||||
|
if (res.status === 409) {
|
||||||
|
alert("You've already rated this quiz!");
|
||||||
|
return;
|
||||||
|
} else if (!res.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (positive) {
|
||||||
|
quiz.likes += 1;
|
||||||
|
} else {
|
||||||
|
quiz.dislikes += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col border-[#B07156] rounded border-2 p-2 gap-2">
|
||||||
|
<div class="grid grid-cols-2 gap-2 group mx-auto">
|
||||||
|
<Hoverable bind:hovering={FeedBackButtonsHovered.like}>
|
||||||
|
<button
|
||||||
|
class="bg-green-500 rounded-full h-10 w-10 transition"
|
||||||
|
use:tippy={{ content: 'Like this quiz!' }}
|
||||||
|
class:opacity-40={FeedBackButtonsHovered.dislike}
|
||||||
|
on:click={() => complete_action(true)}
|
||||||
|
>
|
||||||
|
<!-- heroicons/thumb-up -->
|
||||||
|
<svg
|
||||||
|
class="inline-block h-6 w-6 align-middle text-black"
|
||||||
|
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="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Hoverable>
|
||||||
|
<Hoverable bind:hovering={FeedBackButtonsHovered.dislike}>
|
||||||
|
<button
|
||||||
|
class="rounded-full bg-red-500 h-10 w-10 transition"
|
||||||
|
use:tippy={{ content: 'Dislike this quiz!' }}
|
||||||
|
class:opacity-40={FeedBackButtonsHovered.like}
|
||||||
|
on:click={() => complete_action(false)}
|
||||||
|
>
|
||||||
|
<!-- heroicons/thumb-down -->
|
||||||
|
<svg
|
||||||
|
class="inline-block h-6 w-6 align-middle text-black"
|
||||||
|
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="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Hoverable>
|
||||||
|
<span class="text-center">{quiz.likes}</span>
|
||||||
|
<span class="text-center">{quiz.dislikes}</span>
|
||||||
|
</div>
|
||||||
|
<span class="w-full border-t-2 border-[#B07156]" />
|
||||||
|
<div class="mx-auto grid grid-cols-2 gap-2">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<!-- heroicons/legacy-outline/Play -->
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8"
|
||||||
|
use:tippy={{ content: 'How often the quiz was started' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="mx-auto" use:tippy={{ content: 'How often the quiz was started' }}>
|
||||||
|
{quiz.plays}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<!-- heroicons/legacy-outline/Eye -->
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8 mx-auto"
|
||||||
|
use:tippy={{ content: 'Quiz views' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="mx-auto" use:tippy={{ content: 'Quiz views' }}>{quiz.views}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: MPL-2.0
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Question } 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';
|
||||||
import { navbarVisible, signedIn } from '$lib/stores';
|
import { navbarVisible, signedIn } from '$lib/stores';
|
||||||
@@ -15,20 +15,10 @@ SPDX-License-Identifier: MPL-2.0
|
|||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import StartGamePopup from '$lib/dashboard/start_game.svelte';
|
import StartGamePopup from '$lib/dashboard/start_game.svelte';
|
||||||
|
import Analytics from './Analytics.svelte';
|
||||||
|
|
||||||
// import GrayButton from "$lib/components/buttons/gray.svelte";
|
// import GrayButton from "$lib/components/buttons/gray.svelte";
|
||||||
|
|
||||||
interface QuizData {
|
|
||||||
id: string;
|
|
||||||
public: boolean;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
user_id: string;
|
|
||||||
questions: Question[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
let search_term = '';
|
let search_term = '';
|
||||||
let start_game = null;
|
let start_game = null;
|
||||||
@@ -96,12 +86,16 @@ SPDX-License-Identifier: MPL-2.0
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
let create_button_clicked = false;
|
let create_button_clicked = false;
|
||||||
|
|
||||||
|
let analytics_quiz_selected: undefined | QuizData = undefined;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>ClassQuiz - Dashboard</title>
|
<title>ClassQuiz - Dashboard</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
{#if analytics_quiz_selected}
|
||||||
|
<Analytics bind:quiz={analytics_quiz_selected} />
|
||||||
|
{/if}
|
||||||
<div class="min-h-screen flex flex-col">
|
<div class="min-h-screen flex flex-col">
|
||||||
{#await getData()}
|
{#await getData()}
|
||||||
<svg class="h-8 w-8 animate-spin mx-auto my-20" viewBox="3 3 18 18">
|
<svg class="h-8 w-8 animate-spin mx-auto my-20" viewBox="3 3 18 18">
|
||||||
@@ -198,25 +192,133 @@ SPDX-License-Identifier: MPL-2.0
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-2 grid-rows-2 ml-auto gap-2 w-fit self-end my-auto"
|
class="grid grid-rows-2 ml-auto gap-2 w-fit self-end my-auto"
|
||||||
|
class:grid-cols-3={quiz.type === 'quiz'}
|
||||||
|
class:grid-cols-2={quiz.type !== 'quiztivity'}
|
||||||
>
|
>
|
||||||
|
<BrownButton
|
||||||
|
flex={true}
|
||||||
|
disabled={!quiz.public}
|
||||||
|
href="/view/{quiz.id}"
|
||||||
|
>
|
||||||
|
<!-- heroicons/legacy-outline/Eye -->
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</BrownButton>
|
||||||
|
<BrownButton
|
||||||
|
flex={true}
|
||||||
|
on:click={() => (analytics_quiz_selected = quiz)}
|
||||||
|
>
|
||||||
|
<!-- heroicons/legacy-outline/ChartBar -->
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</BrownButton>
|
||||||
<BrownButton
|
<BrownButton
|
||||||
href={quiz.type === 'quiz'
|
href={quiz.type === 'quiz'
|
||||||
? `/edit?quiz_id=${quiz.id}`
|
? `/edit?quiz_id=${quiz.id}`
|
||||||
: `/quiztivity/edit?id=${quiz.id}`}
|
: `/quiztivity/edit?id=${quiz.id}`}
|
||||||
>{$t('words.edit')}</BrownButton
|
flex={true}
|
||||||
>
|
>
|
||||||
|
<!-- heroicons/legacy-outline/Pencil -->
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</BrownButton>
|
||||||
{#if quiz.type === 'quiz'}
|
{#if quiz.type === 'quiz'}
|
||||||
<BrownButton
|
<BrownButton
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
start_game = quiz.id;
|
start_game = quiz.id;
|
||||||
}}
|
}}
|
||||||
|
flex={true}
|
||||||
>
|
>
|
||||||
{$t('words.start')}
|
<!-- heroicons/legacy-outline/Play -->
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</BrownButton>
|
</BrownButton>
|
||||||
{:else}
|
{:else}
|
||||||
<BrownButton href="/quiztivity/play?id={quiz.id}">
|
<BrownButton href="/quiztivity/play?id={quiz.id}" flex={true}>
|
||||||
{$t('words.play')}
|
<!-- heroicons/legacy-outline/Play -->
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</BrownButton>
|
</BrownButton>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||||
|
|
||||||
|
SPDX-License-Identifier: MPL-2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { QuizData } from '$lib/quiz_types';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { getLocalization } from '$lib/i18n';
|
||||||
|
|
||||||
|
const { t } = getLocalization();
|
||||||
|
|
||||||
|
export let quiz: QuizData | undefined = undefined;
|
||||||
|
|
||||||
|
const on_parent_click = (e: Event) => {
|
||||||
|
if (e.target !== e.currentTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
quiz = undefined;
|
||||||
|
};
|
||||||
|
const close_start_game_if_esc_is_pressed = (key: KeyboardEvent) => {
|
||||||
|
if (key.code === 'Escape') {
|
||||||
|
quiz = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onMount(() => {
|
||||||
|
document.body.addEventListener('keydown', close_start_game_if_esc_is_pressed);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed w-full h-full top-0 flex bg-black bg-opacity-50 z-50"
|
||||||
|
on:click={on_parent_click}
|
||||||
|
transition:fade|local={{ duration: 100 }}
|
||||||
|
>
|
||||||
|
<div class="m-auto bg-white dark:bg-gray-600 rounded shadow-2xl flex p-4 flex-col w-2/3 h-5/6">
|
||||||
|
<h1 class="text-center text-5xl">{$t('words.analytics')}</h1>
|
||||||
|
<section class="flex flex-col gap-2 mt-8">
|
||||||
|
<h2 class="mx-auto text-2xl">{$t('words.rating')}</h2>
|
||||||
|
<table class="w-fit mx-auto">
|
||||||
|
<tr class="border-b-2 dark:border-gray-500 text-left border-gray-300">
|
||||||
|
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
|
||||||
|
>{$t('words.like', { count: 2 })}</th
|
||||||
|
>
|
||||||
|
<th class="p-1 mx-auto">{$t('words.dislike', { count: 2 })}</th>
|
||||||
|
</tr>
|
||||||
|
<tr class="text-left">
|
||||||
|
<td class="border-r dark:border-gray-500 p-1 border-gray-300">{quiz.likes}</td>
|
||||||
|
<td class="mx-auto p-1">{quiz.dislikes}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="flex flex-col gap-2 mt-8">
|
||||||
|
<h2 class="mx-auto text-2xl">{$t('dashboard.views_n_plays')}</h2>
|
||||||
|
<table class="w-fit mx-auto">
|
||||||
|
<tr class="border-b-2 dark:border-gray-500 text-left border-gray-300">
|
||||||
|
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
|
||||||
|
>{$t('words.view', { count: 2 })}</th
|
||||||
|
>
|
||||||
|
<th class="p-1 mx-auto">{$t('words.play', { count: 2 })}</th>
|
||||||
|
</tr>
|
||||||
|
<tr class="text-left">
|
||||||
|
<td class="border-r dark:border-gray-500 p-1 border-gray-300">{quiz.views}</td>
|
||||||
|
<td class="mx-auto p-1">{quiz.plays}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="flex flex-col gap-2 mt-8">
|
||||||
|
<h2 class="mx-auto text-2xl">{$t('words.info')}</h2>
|
||||||
|
<p class="mx-auto max-w-[70%] text-center">
|
||||||
|
{$t('dashboard.info_analytics')}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mt-auto">
|
||||||
|
<p class="mt-6 mx-auto max-w-[70%] text-sm dark:text-gray-200 text-center">
|
||||||
|
Since there's still some space left down here, I guess that I take this opportunity
|
||||||
|
to thank You for using ClassQuiz! Have a great day and continue using ClassQuiz ;)
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -15,6 +15,7 @@ SPDX-License-Identifier: MPL-2.0
|
|||||||
import Spinner from '$lib/Spinner.svelte';
|
import Spinner from '$lib/Spinner.svelte';
|
||||||
import GrayButton from '$lib/components/buttons/gray.svelte';
|
import GrayButton from '$lib/components/buttons/gray.svelte';
|
||||||
import MediaComponent from '$lib/editor/MediaComponent.svelte';
|
import MediaComponent from '$lib/editor/MediaComponent.svelte';
|
||||||
|
import RatingComponent from '$lib/view_quiz/RatingComponent.svelte';
|
||||||
|
|
||||||
const tippy = createTippy({
|
const tippy = createTippy({
|
||||||
arrow: true,
|
arrow: true,
|
||||||
@@ -90,6 +91,9 @@ SPDX-License-Identifier: MPL-2.0
|
|||||||
<div class="text-center text-sm pt-1 mb-4">
|
<div class="text-center text-sm pt-1 mb-4">
|
||||||
<ImportedOrNot imported={quiz.imported_from_kahoot} />
|
<ImportedOrNot imported={quiz.imported_from_kahoot} />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-center mb-2">
|
||||||
|
<RatingComponent bind:quiz />
|
||||||
|
</div>
|
||||||
<div class="flex flex-col justify-center">
|
<div class="flex flex-col justify-center">
|
||||||
<div class="mx-auto flex flex-col gap-2 justify-center w-fit">
|
<div class="mx-auto flex flex-col gap-2 justify-center w-fit">
|
||||||
{#if quiz.imported_from_kahoot && quiz.kahoot_id}
|
{#if quiz.imported_from_kahoot && quiz.kahoot_id}
|
||||||
@@ -108,15 +112,56 @@ SPDX-License-Identifier: MPL-2.0
|
|||||||
on:click={() => {
|
on:click={() => {
|
||||||
start_game = quiz.id;
|
start_game = quiz.id;
|
||||||
}}
|
}}
|
||||||
|
flex={true}
|
||||||
>
|
>
|
||||||
{$t('words.start')}
|
<!-- heroicons/legacy-outline/Play -->
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</GrayButton>
|
</GrayButton>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div use:tippy={{ content: 'You need to be logged in to start a game' }}>
|
<div use:tippy={{ content: 'You need to be logged in to start a game' }}>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<GrayButton disabled={true}>
|
<GrayButton disabled={true} flex={true}>
|
||||||
{$t('words.start')}
|
<!-- heroicons/legacy-outline/Play -->
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</GrayButton>
|
</GrayButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Added Ratings, Plays and Views to Quiz
|
||||||
|
|
||||||
|
Revision ID: 230ac26db527
|
||||||
|
Revises: 32649a1ffcf2
|
||||||
|
Create Date: 2023-06-30 19:18:02.881031
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import ormar
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "230ac26db527"
|
||||||
|
down_revision = "32649a1ffcf2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"rating",
|
||||||
|
sa.Column("id", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=False),
|
||||||
|
sa.Column("user", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
|
||||||
|
sa.Column("positive", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("quiz", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["quiz"], ["quiz.id"], name="fk_rating_quiz_id_quiz"),
|
||||||
|
sa.ForeignKeyConstraint(["user"], ["users.id"], name="fk_rating_users_id_user"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
# op.drop_constraint('fk_api_keys_users_id_user', 'api_keys', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_api_keys_users_id_user', 'api_keys', 'users', ['user'], ['id'], ondelete='CASCADE')
|
||||||
|
# op.drop_constraint('fk_fido_credentials_users_id_user', 'fido_credentials', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_fido_credentials_users_id_user', 'fido_credentials', 'users', ['user'], ['id'], ondelete='CASCADE')
|
||||||
|
# op.drop_constraint('fk_game_results_users_id_user', 'game_results', type_='foreignkey')
|
||||||
|
# op.drop_constraint('fk_game_results_quiz_id_quiz', 'game_results', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_game_results_users_id_user', 'game_results', 'users', ['user'], ['id'], ondelete='CASCADE')
|
||||||
|
# op.create_foreign_key('fk_game_results_quiz_id_quiz', 'game_results', 'quiz', ['quiz'], ['id'], ondelete='CASCADE')
|
||||||
|
op.add_column("quiz", sa.Column("likes", sa.Integer(), server_default="0", nullable=False))
|
||||||
|
op.add_column("quiz", sa.Column("dislikes", sa.Integer(), server_default="0", nullable=False))
|
||||||
|
op.add_column("quiz", sa.Column("plays", sa.Integer(), server_default="0", nullable=False))
|
||||||
|
op.add_column("quiz", sa.Column("views", sa.Integer(), server_default="0", nullable=False))
|
||||||
|
# op.drop_constraint('fk_quiz_users_id_user_id', 'quiz', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_quiz_users_id_user_id', 'quiz', 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||||
|
# op.drop_constraint('fk_quiztivitys_users_id_user', 'quiztivitys', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_quiztivitys_users_id_user', 'quiztivitys', 'users', ['user'], ['id'], ondelete='CASCADE')
|
||||||
|
# op.drop_constraint('fk_quiztivityshares_users_id_user', 'quiztivityshares', type_='foreignkey')
|
||||||
|
# op.drop_constraint('fk_quiztivityshares_quiztivitys_id_quiztivity', 'quiztivityshares', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_quiztivityshares_users_id_user', 'quiztivityshares', 'users', ['user'], ['id'], ondelete='CASCADE')
|
||||||
|
# op.create_foreign_key('fk_quiztivityshares_quiztivitys_id_quiztivity', 'quiztivityshares', 'quiztivitys', ['quiztivity'], ['id'], ondelete='CASCADE')
|
||||||
|
# op.drop_constraint('fk_storage_items_users_id_user', 'storage_items', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_storage_items_users_id_user', 'storage_items', 'users', ['user'], ['id'], ondelete='SET NULL')
|
||||||
|
# op.drop_constraint('fk_user_sessions_users_id_user', 'user_sessions', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_user_sessions_users_id_user', 'user_sessions', 'users', ['user'], ['id'], ondelete='CASCADE')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
# op.drop_constraint('fk_user_sessions_users_id_user', 'user_sessions', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_user_sessions_users_id_user', 'user_sessions', 'users', ['user'], ['id'])
|
||||||
|
# op.drop_constraint('fk_storage_items_users_id_user', 'storage_items', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_storage_items_users_id_user', 'storage_items', 'users', ['user'], ['id'])
|
||||||
|
# op.drop_constraint('fk_quiztivityshares_quiztivitys_id_quiztivity', 'quiztivityshares', type_='foreignkey')
|
||||||
|
# op.drop_constraint('fk_quiztivityshares_users_id_user', 'quiztivityshares', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_quiztivityshares_quiztivitys_id_quiztivity', 'quiztivityshares', 'quiztivitys', ['quiztivity'], ['id'])
|
||||||
|
# op.create_foreign_key('fk_quiztivityshares_users_id_user', 'quiztivityshares', 'users', ['user'], ['id'])
|
||||||
|
# op.drop_constraint('fk_quiztivitys_users_id_user', 'quiztivitys', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_quiztivitys_users_id_user', 'quiztivitys', 'users', ['user'], ['id'])
|
||||||
|
# op.drop_constraint('fk_quiz_users_id_user_id', 'quiz', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_quiz_users_id_user_id', 'quiz', 'users', ['user_id'], ['id'])
|
||||||
|
op.drop_column("quiz", "views")
|
||||||
|
op.drop_column("quiz", "plays")
|
||||||
|
op.drop_column("quiz", "dislikes")
|
||||||
|
op.drop_column("quiz", "likes")
|
||||||
|
# op.drop_constraint('fk_game_results_quiz_id_quiz', 'game_results', type_='foreignkey')
|
||||||
|
# op.drop_constraint('fk_game_results_users_id_user', 'game_results', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_game_results_quiz_id_quiz', 'game_results', 'quiz', ['quiz'], ['id'])
|
||||||
|
# op.create_foreign_key('fk_game_results_users_id_user', 'game_results', 'users', ['user'], ['id'])
|
||||||
|
# op.drop_constraint('fk_fido_credentials_users_id_user', 'fido_credentials', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_fido_credentials_users_id_user', 'fido_credentials', 'users', ['user'], ['id'])
|
||||||
|
# op.drop_constraint('fk_api_keys_users_id_user', 'api_keys', type_='foreignkey')
|
||||||
|
# op.create_foreign_key('fk_api_keys_users_id_user', 'api_keys', 'users', ['user'], ['id'])
|
||||||
|
op.drop_table("rating")
|
||||||
|
# ### end Alembic commands ###
|
||||||
Reference in New Issue
Block a user