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 @@ + + + + +
+ +
diff --git a/frontend/src/lib/view_quiz/RatingComponent.svelte b/frontend/src/lib/view_quiz/RatingComponent.svelte new file mode 100644 index 0000000..92f2424 --- /dev/null +++ b/frontend/src/lib/view_quiz/RatingComponent.svelte @@ -0,0 +1,154 @@ + + + + +
+
+ + + + + + + {quiz.likes} + {quiz.dislikes} +
+ +
+
+ + +

+ {quiz.plays} +

+
+
+ + +

{quiz.views}

+
+
+
diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 3f9d730..3a77b5d 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -5,7 +5,7 @@ SPDX-License-Identifier: MPL-2.0 --> ClassQuiz - Dashboard - +{#if analytics_quiz_selected} + +{/if}
{#await getData()} @@ -198,25 +192,133 @@ SPDX-License-Identifier: MPL-2.0

+ + + + + (analytics_quiz_selected = quiz)} + > + + + {$t('words.edit')} + + + {#if quiz.type === 'quiz'} { start_game = quiz.id; }} + flex={true} > - {$t('words.start')} + + {:else} - - {$t('words.play')} + + + {/if} diff --git a/frontend/src/routes/dashboard/Analytics.svelte b/frontend/src/routes/dashboard/Analytics.svelte new file mode 100644 index 0000000..5df9470 --- /dev/null +++ b/frontend/src/routes/dashboard/Analytics.svelte @@ -0,0 +1,83 @@ + + + + +
+
+

{$t('words.analytics')}

+
+

{$t('words.rating')}

+ + + + + + + + + +
{$t('words.like', { count: 2 })}{$t('words.dislike', { count: 2 })}
{quiz.likes}{quiz.dislikes}
+
+
+

{$t('dashboard.views_n_plays')}

+ + + + + + + + + +
{$t('words.view', { count: 2 })}{$t('words.play', { count: 2 })}
{quiz.views}{quiz.plays}
+
+
+

{$t('words.info')}

+

+ {$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 ;) +

+
+
+
diff --git a/frontend/src/routes/view/[quiz_id]/+page.svelte b/frontend/src/routes/view/[quiz_id]/+page.svelte index 1c29607..bb9ca31 100644 --- a/frontend/src/routes/view/[quiz_id]/+page.svelte +++ b/frontend/src/routes/view/[quiz_id]/+page.svelte @@ -15,6 +15,7 @@ SPDX-License-Identifier: MPL-2.0 import Spinner from '$lib/Spinner.svelte'; import GrayButton from '$lib/components/buttons/gray.svelte'; import MediaComponent from '$lib/editor/MediaComponent.svelte'; + import RatingComponent from '$lib/view_quiz/RatingComponent.svelte'; const tippy = createTippy({ arrow: true, @@ -90,6 +91,9 @@ SPDX-License-Identifier: MPL-2.0
+
+ +
{#if quiz.imported_from_kahoot && quiz.kahoot_id} @@ -108,15 +112,56 @@ SPDX-License-Identifier: MPL-2.0 on:click={() => { start_game = quiz.id; }} + flex={true} > - {$t('words.start')} + +
{:else}
- - {$t('words.start')} + + +
diff --git a/migrations/versions/230ac26db527_added_ratings_plays_and_views_to_quiz.py b/migrations/versions/230ac26db527_added_ratings_plays_and_views_to_quiz.py new file mode 100644 index 0000000..5ec8fc6 --- /dev/null +++ b/migrations/versions/230ac26db527_added_ratings_plays_and_views_to_quiz.py @@ -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 ###