diff --git a/classquiz/__init__.py b/classquiz/__init__.py index 8baaf10..b966a5a 100644 --- a/classquiz/__init__.py +++ b/classquiz/__init__.py @@ -33,6 +33,7 @@ from classquiz.routers import ( box_controller, quiztivity, pixabay, + moderation, ) from classquiz.socket_server import sio from classquiz.helpers import meilisearch_init, telemetry_ping @@ -77,6 +78,7 @@ async def auth_middleware_wrapper(request: Request, call_next): return await rememberme_middleware(request, call_next) +app.include_router(moderation.router, tags=["moderation"], prefix="/api/v1/moderation", include_in_schema=True) app.include_router(pixabay.router, tags=["pixabay"], prefix="/api/v1/pixabay", include_in_schema=True) app.include_router(quiztivity.router, tags=["quiztivity"], prefix="/api/v1/quiztivity", include_in_schema=True) diff --git a/classquiz/auth.py b/classquiz/auth.py index 93f4c23..3b683a2 100644 --- a/classquiz/auth.py +++ b/classquiz/auth.py @@ -128,6 +128,23 @@ async def get_current_user(token: str = Depends(oauth2_scheme)): return user +async def get_current_moderator(token: str = Depends(oauth2_scheme)): + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) + email: str = payload.get("sub") + if email is None: + raise credentials_exception + token_data = TokenData(email=email) + except JWTError: + raise credentials_exception + user = await get_user_from_mail(email=token_data.email) + if user is None: + raise credentials_exception + if user.username not in settings.mods: + raise credentials_exception + return user + + async def get_admin_user(token: str = Depends(oauth2_scheme)) -> User: user = await get_current_user(token) admin_user = await User.objects.order_by(User.created_at.asc()).get() diff --git a/classquiz/config.py b/classquiz/config.py index 4bf50b8..2b44b65 100644 --- a/classquiz/config.py +++ b/classquiz/config.py @@ -53,6 +53,7 @@ class Settings(BaseSettings): telemetry_enabled: bool = True free_storage_limit: int = 1074000000 pixabay_api_key: str | None = None + mods: list[str] = [] # storage_backend storage_backend: str | None = "local" diff --git a/classquiz/db/models.py b/classquiz/db/models.py index bf79a87..95511f7 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -184,6 +184,7 @@ class Quiz(ormar.Model): 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") + mod_rating: int | None = ormar.SmallInteger(nullable=True) class Meta: tablename = "quiz" diff --git a/classquiz/helpers/__init__.py b/classquiz/helpers/__init__.py index bef4394..388b3c4 100644 --- a/classquiz/helpers/__init__.py +++ b/classquiz/helpers/__init__.py @@ -170,6 +170,7 @@ async def handle_import_from_excel(data: BinaryIO, user: User) -> Quiz: else: existing_quiz.questions = [*existing_quiz.questions, *questions] existing_quiz.updated_at = datetime.now() + existing_quiz.mod_rating = None await existing_quiz.update() quiz = existing_quiz return quiz diff --git a/classquiz/routers/editor.py b/classquiz/routers/editor.py index 54159cb..43d7238 100644 --- a/classquiz/routers/editor.py +++ b/classquiz/routers/editor.py @@ -129,6 +129,7 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput): quiz.cover_image = quiz_input.cover_image quiz.background_color = quiz_input.background_color quiz.background_image = quiz_input.background_image + quiz.mod_rating = None for image in images_to_delete: if image is not None: try: diff --git a/classquiz/routers/eximport.py b/classquiz/routers/eximport.py index 3c54899..4ded1bd 100644 --- a/classquiz/routers/eximport.py +++ b/classquiz/routers/eximport.py @@ -128,5 +128,6 @@ async def import_quiz(file: UploadFile = File(), user: User = Depends(get_curren quiz = Quiz.parse_obj(quiz_dict) quiz.user_id = user.id quiz.imported_from_kahoot = None + quiz.mod_rating = None await quiz.save() return quiz diff --git a/classquiz/routers/moderation.py b/classquiz/routers/moderation.py new file mode 100644 index 0000000..856946b --- /dev/null +++ b/classquiz/routers/moderation.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) +# +# SPDX-License-Identifier: MPL-2.0 +import uuid + +from fastapi import APIRouter, Depends, Response, HTTPException +from pydantic import BaseModel + +from classquiz.auth import get_current_moderator +from classquiz.db.models import User, Quiz + +router = APIRouter() + + +@router.get("/status") +async def get_mod_status(resp: Response, user: User = Depends(get_current_moderator)): + resp.status_code = 200 + resp.set_cookie("moderator", "yes", path="/") + resp.headers.update({"Content-Type": "application/json"}) + resp.body = '{"status": "ok"}' + return resp + + +@router.get("/quizzes") +async def get_newest_quizzes( + page: int = 1, all: bool = False, user: User = Depends(get_current_moderator) +) -> list[Quiz]: + if page < 1: + raise HTTPException(status_code=400, detail="page 1 is the first") + if all: + quizzes = ( + await Quiz.objects.paginate(page=page) + .order_by(Quiz.updated_at.desc()) + .filter(Quiz.public == True) # noqa: E712 + .all() + ) + else: + # noinspection PyComparisonWithNone + quizzes = ( + await Quiz.objects.paginate(page=page) + .order_by(Quiz.updated_at.desc()) + .filter(Quiz.public == True) # noqa: E712 + .filter(Quiz.mod_rating == None) # noqa: E711 + .all() + ) + return quizzes + + +class SetModRatingForQuizInput(BaseModel): + rating: int | None + + +@router.post("/rating/set/{quiz_id}") +async def set_mod_rating_for_quiz( + data: SetModRatingForQuizInput, quiz_id: uuid.UUID, user: User = Depends(get_current_moderator) +) -> Quiz: + quiz = await Quiz.objects.get_or_none(public=True, id=quiz_id) + if quiz is None: + raise HTTPException(status_code=404, detail="Quiz not found") + quiz.mod_rating = data.rating + return await quiz.update() diff --git a/frontend/src/lib/collapsible.svelte b/frontend/src/lib/collapsible.svelte index 36af910..ac8be0d 100644 --- a/frontend/src/lib/collapsible.svelte +++ b/frontend/src/lib/collapsible.svelte @@ -7,7 +7,7 @@ SPDX-License-Identifier: MPL-2.0
diff --git a/frontend/src/routes/moderation/+page.svelte b/frontend/src/routes/moderation/+page.svelte new file mode 100644 index 0000000..0392f5f --- /dev/null +++ b/frontend/src/routes/moderation/+page.svelte @@ -0,0 +1,51 @@ + + + + +
+ {#each data.quizzes as quiz} +
+
+ +
+

{@html quiz.title}

+

+ {@html quiz.description ?? ''} +

+
+
+

Questions: {quiz.questions.length}

+
+
+
+ View +
+
+ {/each} +
+
+
+ Previous Page +

Page {data.page}

+ Next Page +
+
diff --git a/frontend/src/routes/moderation/+page.ts b/frontend/src/routes/moderation/+page.ts new file mode 100644 index 0000000..90bed46 --- /dev/null +++ b/frontend/src/routes/moderation/+page.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) +// +// SPDX-License-Identifier: MPL-2.0 +import type { PageLoad } from './$types'; + +export const load = (async ({ fetch, url }) => { + const page = url.searchParams.get('page') ?? '1'; + const all = Boolean(url.searchParams.get('all')) ?? false; + const resp = await fetch( + `/api/v1/moderation/quizzes?page=${page}&all=${all ? 'true' : 'false'}` + ); + const quizzes = await resp.json(); + return { + page, + all, + quizzes + }; +}) satisfies PageLoad; diff --git a/frontend/src/routes/view/[quiz_id]/+page.svelte b/frontend/src/routes/view/[quiz_id]/+page.svelte index bb9ca31..02a72c7 100644 --- a/frontend/src/routes/view/[quiz_id]/+page.svelte +++ b/frontend/src/routes/view/[quiz_id]/+page.svelte @@ -4,7 +4,7 @@ SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) SPDX-License-Identifier: MPL-2.0 --> - @@ -69,45 +76,48 @@ SPDX-License-Identifier: MPL-2.0
-

{@html quiz.title}

-
+

{@html quiz.title}

+

{@html quiz.description}

-

+

{$t('view_quiz_page.made_by')} - @{quiz.user_id.username} + @{quiz.user_id.username}

{#if quiz.cover_image} -
-
+
+
Not provided
{/if} -
+
-
+
+ {#if mod_view} + + {/if}
-
-
+
+
{#if quiz.imported_from_kahoot && quiz.kahoot_id} -
+
{$t('view_quiz_page.view_on_kahoot')}
{/if} {#if logged_in} -
+
{ start_game = quiz.id; @@ -116,76 +126,76 @@ SPDX-License-Identifier: MPL-2.0 >
{:else}
-
+
{/if} -
- +
+ {$t('words.practice')}
-
+
{#if logged_in} - + {$t('words.download')} @@ -194,17 +204,17 @@ SPDX-License-Identifier: MPL-2.0
{$t('words.download')} @@ -213,10 +223,10 @@ SPDX-License-Identifier: MPL-2.0 {/if}
-
+
{$t('words.report')} @@ -224,10 +234,10 @@ SPDX-License-Identifier: MPL-2.0
{#each quiz.questions as question, index_question} -
- -
-

+
+ +
+

{index_question + 1}: {@html question.question}

@@ -237,41 +247,41 @@ SPDX-License-Identifier: MPL-2.0 {#if question.image} {/if}

- {question.time}s + {question.time}s

{#if question.type === QuizQuestionType.ABCD || question.type === undefined || question.type === QuizQuestionType.CHECK} -
+
{#each question.answers as answer, index_answer}
-

+

{quiz.questions[index_question].answers[index_answer] .answer}

@@ -279,26 +289,26 @@ SPDX-License-Identifier: MPL-2.0 {/each}
{:else if question.type === QuizQuestionType.RANGE} -

+

All numbers between {question.answers.min_correct} and {question.answers.max_correct} are correct, where numbers between {question - .answers.min} and {question.answers.max} can be selected. + .answers.min} and {question.answers.max} can be selected.

{:else if question.type === QuizQuestionType.ORDER} -
    +
      {#each question.answers as answer} -
    • -

      +
    • +

      {answer.answer}

    • {/each}

    {:else if question.type === QuizQuestionType.VOTING || question.type === QuizQuestionType.TEXT} -
    +
    {#each question.answers as answer, index_answer} -
    -

    +
    +

    {quiz.questions[index_question].answers[index_answer] .answer}

    @@ -309,7 +319,7 @@ SPDX-License-Identifier: MPL-2.0 {#await import('$lib/play/admin/slide.svelte')} {:then c} -
    +
    {/await} diff --git a/frontend/src/routes/view/[quiz_id]/ModComponent.svelte b/frontend/src/routes/view/[quiz_id]/ModComponent.svelte new file mode 100644 index 0000000..04fad8b --- /dev/null +++ b/frontend/src/routes/view/[quiz_id]/ModComponent.svelte @@ -0,0 +1,55 @@ + + + + +
    +
    + mod_rating = null}>Not Checked +
    +
    + mod_rating = 0}>Ok +
    +
    + mod_rating = 1}>Attention +
    +
    + mod_rating = 2}>NFSW +
    +
    + mod_rating = 3}>Plausibility Checked +
    +
    + mod_rating = 4}>Fact Checked +
    +
    + mod_rating = 5}>Exceptional +
    + Submit +
    diff --git a/migrations/versions/9d7fa2e6b24c_added_mod_rating.py b/migrations/versions/9d7fa2e6b24c_added_mod_rating.py new file mode 100644 index 0000000..83a8177 --- /dev/null +++ b/migrations/versions/9d7fa2e6b24c_added_mod_rating.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) +# +# SPDX-License-Identifier: MPL-2.0 + +"""Added Mod Rating + +Revision ID: 9d7fa2e6b24c +Revises: 2ed6823c69b2 +Create Date: 2023-08-01 16:06:22.419662 + +""" +from alembic import op +import sqlalchemy as sa +import ormar + + +# revision identifiers, used by Alembic. +revision = "9d7fa2e6b24c" +down_revision = "2ed6823c69b2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("quiz", sa.Column("mod_rating", sa.SmallInteger(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("quiz", "mod_rating") + # ### end Alembic commands ###