diff --git a/classquiz/db/models.py b/classquiz/db/models.py index ccf8a34..bf79a87 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -180,6 +180,10 @@ class Quiz(ormar.Model): background_color: 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) + 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: tablename = "quiz" @@ -472,3 +476,16 @@ class Controller(ormar.Model): tablename = "controller" metadata = metadata 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 diff --git a/classquiz/routers/community.py b/classquiz/routers/community.py index 29dec44..a7d95e3 100644 --- a/classquiz/routers/community.py +++ b/classquiz/routers/community.py @@ -1,12 +1,17 @@ # SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) # # SPDX-License-Identifier: MPL-2.0 +import enum +import uuid +from datetime import datetime - -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Depends 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() @@ -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") else: 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() diff --git a/classquiz/routers/quiz.py b/classquiz/routers/quiz.py index ec5133c..1115a5f 100644 --- a/classquiz/routers/quiz.py +++ b/classquiz/routers/quiz.py @@ -56,18 +56,20 @@ class PublicQuizResponseUser(BaseModel): class PublicQuizResponse(Quiz.get_pydantic()): user_id: PublicQuizResponseUser questions: list[QuizQuestion] + likes: int + dislikes: int + views: int + plays: int @router.get("/get/public/{quiz_id}") -async def get_public_quiz(quiz_id: str): - try: - quiz_id = uuid.UUID(quiz_id) - except ValueError: - raise HTTPException(status_code=400, detail="badly formed quiz id") +async def get_public_quiz(quiz_id: uuid.UUID): quiz = await Quiz.objects.select_related("user_id").get_or_none(id=quiz_id) if quiz is None: return JSONResponse(status_code=404, content={"detail": "quiz not found"}) else: + quiz.views += 1 + await quiz.update() return PublicQuizResponse(**quiz.dict()) @@ -89,6 +91,8 @@ async def start_quiz( quiz = await Quiz.objects.get_or_none(id=quiz_id, public=True) if quiz is None: return JSONResponse(status_code=404, content={"detail": "quiz not found"}) + quiz.plays += 1 + await quiz.update() game_pin = randint(100000, 999999) if custom_field == "": custom_field = None diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index bcd845c..ad0196d 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -130,6 +130,7 @@ "screenshot_plural": "Screenshots", "browser": "Browser", "view": "View", + "view_plural": "Views", "correct": "Correct", "result": "Result", "result_plural": "Results", @@ -182,7 +183,15 @@ "files_library": "Files Library", "answer_plural": "Answers", "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": { "time_in_seconds": "Time in seconds", @@ -281,7 +290,9 @@ "right_click_to_delete": "Right-click on an answer to delete it!" }, "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": { "self_ads": "Made with ❤️ by {{mawoka_link}} and with the help of {{others_link}}.", diff --git a/frontend/src/lib/quiz_types.ts b/frontend/src/lib/quiz_types.ts index 2f809d7..6c615ca 100644 --- a/frontend/src/lib/quiz_types.ts +++ b/frontend/src/lib/quiz_types.ts @@ -21,6 +21,10 @@ export interface QuizData { cover_image?: string; background_color?: string; background_image?: string; + likes: number; + dislikes: number; + plays: number; + views: number; } export enum QuizQuestionType { diff --git a/frontend/src/lib/view_quiz/Hoverable.svelte b/frontend/src/lib/view_quiz/Hoverable.svelte new file mode 100644 index 0000000..02efd9b --- /dev/null +++ b/frontend/src/lib/view_quiz/Hoverable.svelte @@ -0,0 +1,21 @@ + + + + +
+ {quiz.plays} +
+{quiz.views}
+| {$t('words.like', { count: 2 })} | +{$t('words.dislike', { count: 2 })} | +
|---|---|
| {quiz.likes} | +{quiz.dislikes} | +
| {$t('words.view', { count: 2 })} | +{$t('words.play', { count: 2 })} | +
|---|---|
| {quiz.views} | +{quiz.plays} | +
+ {$t('dashboard.info_analytics')} +
++ 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 ;) +
+