From c3a095b16ee32943f6678e53c46cbbb292605ee5 Mon Sep 17 00:00:00 2001 From: Lauritz Tieste Date: Wed, 17 Jul 2024 19:53:19 +0200 Subject: [PATCH] Add bookmarking endpoints --- .../versions/2024_07_06_2009-4f7559aa1483_.py | 31 +++++ api/endpoints/__init__.py | 4 +- api/endpoints/bookmarks.py | 111 ++++++++++++++++++ api/endpoints/skill.py | 44 +++++-- api/exceptions/bookmarks.py | 15 +++ api/exceptions/skill.py | 6 + api/models/__init__.py | 2 + api/models/bookmarks.py | 42 +++++++ api/models/sub_skill.py | 16 ++- api/schemas/skill.py | 4 + 10 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 alembic/versions/2024_07_06_2009-4f7559aa1483_.py create mode 100644 api/endpoints/bookmarks.py create mode 100644 api/exceptions/bookmarks.py create mode 100644 api/models/bookmarks.py diff --git a/alembic/versions/2024_07_06_2009-4f7559aa1483_.py b/alembic/versions/2024_07_06_2009-4f7559aa1483_.py new file mode 100644 index 0000000..c8fe206 --- /dev/null +++ b/alembic/versions/2024_07_06_2009-4f7559aa1483_.py @@ -0,0 +1,31 @@ +"""empty message + +Revision ID: 4f7559aa1483 +Create Date: 2024-07-06 20:09:48.351518 +""" + +from alembic import op + +import sqlalchemy as sa + + +revision = "4f7559aa1483" +down_revision = "1a9e957978e2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "skills_sub_skill_bookmark", + sa.Column("bookmark_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=True), + sa.Column("root_skill_id", sa.String(length=256), nullable=True), + sa.Column("sub_skill_id", sa.String(length=256), nullable=True), + sa.PrimaryKeyConstraint("bookmark_id"), + mysql_collate="utf8mb4_bin", + ) + + +def downgrade() -> None: + op.drop_table("skills_sub_skill_bookmark") diff --git a/api/endpoints/__init__.py b/api/endpoints/__init__.py index e73c965..53a8d32 100644 --- a/api/endpoints/__init__.py +++ b/api/endpoints/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from . import course, skill, xp +from . import bookmarks, course, skill, xp from .internal import INTERNAL_ROUTERS from ..auth import internal_auth @@ -10,7 +10,7 @@ ROUTER = APIRouter() TAGS: list[dict[str, Any]] = [] -for module in [skill, course, xp]: +for module in [skill, bookmarks, course, xp]: name = module.__name__.split(".")[-1] router = APIRouter(tags=[name]) router.include_router(module.router) diff --git a/api/endpoints/bookmarks.py b/api/endpoints/bookmarks.py new file mode 100644 index 0000000..ec759c6 --- /dev/null +++ b/api/endpoints/bookmarks.py @@ -0,0 +1,111 @@ +"""Endpoints related to bookmarking""" + +from typing import Any + +from fastapi import APIRouter + +from api import models +from api.auth import require_verified_email, user_auth +from api.database import db +from api.exceptions.skill import SkillNotFoundException, SubSkillNotFoundException +from api.schemas.user import User +from api.utils.docs import responses + + +router = APIRouter() + + +@router.post( + "/bookmark/{root_skill_id}", + dependencies=[require_verified_email], + responses=responses(bool, SkillNotFoundException), +) +async def create_bookmark(user: User = user_auth, root_skill_id: str = None) -> Any: + """ + Create a new bookmark on a root skill. + This bookmarks all subskills under the root skill. + + *Requirements:* **VERIFIED** + """ + root_skill: models.RootSkill | None = await db.get(models.RootSkill, id=root_skill_id) + if root_skill is None: + raise SkillNotFoundException + + for subskill in root_skill.sub_skills: + try: + await create_sub_skill_bookmark(user, root_skill_id, subskill.id) + except Exception: + pass + + return True + + +@router.delete( + "/bookmark/{root_skill_id}", + dependencies=[require_verified_email], + responses=responses(bool, SkillNotFoundException), +) +async def delete_bookmark(user: User = user_auth, root_skill_id: str = None) -> Any: + """ + Delete a bookmark on a root skill. + This deletes all subskill bookmarks under the root skill. + + *Requirements:* **VERIFIED** + """ + root_skill: models.RootSkill | None = await db.get(models.RootSkill, id=root_skill_id) + if root_skill is None: + raise SkillNotFoundException + + for subskill in root_skill.sub_skills: + try: + await delete_sub_skill_bookmark(user, root_skill_id, subskill.id) + except Exception: + pass + + return True + + +@router.post( + "/bookmark/{root_skill_id}/{sub_skill_id}", + dependencies=[require_verified_email], + responses=responses(bool, SkillNotFoundException), +) +async def create_sub_skill_bookmark(user: User = user_auth, root_skill_id: str = None, sub_skill_id: str = None) -> Any: + """ + Create a new bookmark on a sub skill. + This also bookmarks the root skill. + + *Requirements:* **VERIFIED** + """ + root_skill: models.RootSkill | None = await db.get(models.RootSkill, id=root_skill_id) + if root_skill is None: + raise SkillNotFoundException + + sub_skill: models.SubSkill | None = await db.get(models.SubSkill, id=sub_skill_id, parent_id=root_skill_id) + if sub_skill is None: + raise SubSkillNotFoundException + + return await models.SubSkillBookmark.create(user_id=user.id, root_skill_id=root_skill_id, sub_skill_id=sub_skill_id) + + +@router.delete( + "/bookmark/{root_skill_id}/{sub_skill_id}", + dependencies=[require_verified_email], + responses=responses(bool, SkillNotFoundException), +) +async def delete_sub_skill_bookmark(user: User = user_auth, root_skill_id: str = None, sub_skill_id: str = None) -> Any: + """ + Delete a bookmark on a sub skill. + This also deletes the root skill bookmark. + + *Requirements:* **VERIFIED** + """ + root_skill: models.RootSkill | None = await db.get(models.RootSkill, id=root_skill_id) + if root_skill is None: + raise SkillNotFoundException + + sub_skill: models.SubSkill | None = await db.get(models.SubSkill, id=sub_skill_id, parent_id=root_skill_id) + if sub_skill is None: + raise SubSkillNotFoundException + + return await models.SubSkillBookmark.delete(user_id=user.id, root_skill_id=root_skill_id, sub_skill_id=sub_skill_id) diff --git a/api/endpoints/skill.py b/api/endpoints/skill.py index 094721d..bd70b3a 100644 --- a/api/endpoints/skill.py +++ b/api/endpoints/skill.py @@ -66,17 +66,27 @@ def get_skill_dependents(skill_id: str, skills: dict[str, models.RootSkill] | di @router.get("/skilltree", responses=responses(SkillTree)) -@redis_cached("skills") -async def list_root_skills() -> Any: +@redis_cached("skills", "user") +async def list_root_skills(user: User | None = public_auth) -> Any: """Return a list of all root skills.""" - settings = await models.TreeSettings.get() - return SkillTree( - skills=[skill.serialize async for skill in await db.stream(select(models.RootSkill))], - rows=settings.rows, - columns=settings.columns, - ) + if user: + bookmarked_skill_ids = [] + + for bookmark in await db.all(filter_by(models.SubSkillBookmark, user_id=user.id)): + bookmarked_skill_ids.append(bookmark.root_skill_id) + + skills = [ + {**skill.serialize, "isBookmarked": skill.id in bookmarked_skill_ids} + async for skill in await db.stream(select(models.RootSkill)) + ] + else: + skills = [ + {**skill.serialize, "isBookmarked": None} async for skill in await db.stream(select(models.RootSkill)) + ] + + return SkillTree(skills=skills, rows=settings.rows, columns=settings.columns) @router.patch("/skilltree", dependencies=[admin_auth], responses=admin_responses(UpdateRootTree)) @@ -208,11 +218,19 @@ async def list_sub_skills(*, root_skill_id: str, user: User | None = public_auth root_skill: models.RootSkill = await get_root_skill.dependency(root_skill_id) - return SubSkillTree( - skills=[sub_skill.serialize for sub_skill in root_skill.sub_skills], - rows=root_skill.sub_tree_rows, - columns=root_skill.sub_tree_columns, - ) + if user: + bookmarked_skill_ids = [] + for bookmark in await db.all(filter_by(models.SubSkillBookmark, user_id=user.id, root_skill_id=root_skill_id)): + bookmarked_skill_ids.append(bookmark.sub_skill_id) + + skills = [ + {**sub_skill.serialize_dict, "isBookmarked": sub_skill.id in bookmarked_skill_ids} + for sub_skill in root_skill.sub_skills + ] + else: + skills = [{**sub_skill.serialize_dict, "isBookmarked": None} for sub_skill in root_skill.sub_skills] + + return SubSkillTree(skills=skills, rows=root_skill.sub_tree_rows, columns=root_skill.sub_tree_columns) @router.post( diff --git a/api/exceptions/bookmarks.py b/api/exceptions/bookmarks.py new file mode 100644 index 0000000..53a06dc --- /dev/null +++ b/api/exceptions/bookmarks.py @@ -0,0 +1,15 @@ +from starlette import status + +from api.exceptions.api_exception import APIException + + +class SubSkillAlreadyBookmarkedException(APIException): + status_code = status.HTTP_409_CONFLICT + detail = "Subskill already bookmarked" + description = "The requested sub skill is already bookmarked." + + +class BookmarkNotFoundException(APIException): + status_code = status.HTTP_404_NOT_FOUND + detail = "Bookmark not found" + description = "The requested bookmark was not found." diff --git a/api/exceptions/skill.py b/api/exceptions/skill.py index e07d42d..612cffa 100644 --- a/api/exceptions/skill.py +++ b/api/exceptions/skill.py @@ -9,6 +9,12 @@ class SkillNotFoundException(APIException): description = "The requested skill does not exist." +class SubSkillNotFoundException(APIException): + status_code = status.HTTP_404_NOT_FOUND + detail = "Sub skill not found" + description = "The requested sub skill does not exist." + + class SkillAlreadyExistsException(APIException): status_code = status.HTTP_409_CONFLICT detail = "Skill already exists" diff --git a/api/models/__init__.py b/api/models/__init__.py index 2993e5e..a9f121a 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -1,3 +1,4 @@ +from .bookmarks import SubSkillBookmark from .course_access import CourseAccess from .last_watch import LastWatch from .lecture_progress import LectureProgress @@ -18,4 +19,5 @@ "SubSkillDependency", "TreeSettings", "XP", + "SubSkillBookmark", ] diff --git a/api/models/bookmarks.py b/api/models/bookmarks.py new file mode 100644 index 0000000..44b930b --- /dev/null +++ b/api/models/bookmarks.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from sqlalchemy import Column, Integer, String + +from api.database import Base, db, filter_by +from api.exceptions.bookmarks import BookmarkNotFoundException, SubSkillAlreadyBookmarkedException +from api.utils.cache import clear_cache + + +class SubSkillBookmark(Base): + __tablename__ = "skills_sub_skill_bookmark" + + bookmark_id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(String(36)) + root_skill_id = Column(String(256)) + sub_skill_id = Column(String(256)) + + @staticmethod + async def create(user_id: str, root_skill_id: str, sub_skill_id: str) -> bool: + if await db.exists( + filter_by(SubSkillBookmark, user_id=user_id, sub_skill_id=sub_skill_id, root_skill_id=root_skill_id) + ): + raise SubSkillAlreadyBookmarkedException + + bookmark = SubSkillBookmark(user_id=user_id, sub_skill_id=sub_skill_id, root_skill_id=root_skill_id) + response = await db.add(bookmark) + await clear_cache("skills") + + return bool(response) + + @staticmethod + async def delete(user_id: str, root_skill_id: str, sub_skill_id: str) -> bool: + bookmark = await db.get( + SubSkillBookmark, user_id=user_id, sub_skill_id=sub_skill_id, root_skill_id=root_skill_id + ) + if bookmark is None: + raise BookmarkNotFoundException + + response = await db.delete(bookmark) + await clear_cache("skills") + + return bool(response) diff --git a/api/models/sub_skill.py b/api/models/sub_skill.py index 32feae7..b9b1248 100644 --- a/api/models/sub_skill.py +++ b/api/models/sub_skill.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from sqlalchemy import Column, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, relationship @@ -68,3 +68,17 @@ def serialize(self) -> schemas.SubSkill: column=self.column, icon=self.icon, ) + + @property + def serialize_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "parent_id": self.parent_id, + "name": self.name, + "dependencies": [dependency.id for dependency in self.dependencies], + "dependents": [dependent.id for dependent in self.dependents], + "courses": [course.course_id for course in self.courses], + "row": self.row, + "column": self.column, + "icon": self.icon, + } diff --git a/api/schemas/skill.py b/api/schemas/skill.py index b540e9a..da18c5a 100644 --- a/api/schemas/skill.py +++ b/api/schemas/skill.py @@ -14,6 +14,7 @@ class RootSkill(BaseModel): sub_tree_rows: int = Field(description="Number of rows in the sub skill tree") sub_tree_columns: int = Field(description="Number of columns in the sub skill tree") icon: str | None = Field(description="Icon of the skill") + isBookmarked: bool | None = Field(description="Indicates if the skill is bookmarked") Config = example( id="datenbank_experte", @@ -26,6 +27,7 @@ class RootSkill(BaseModel): sub_tree_rows=20, sub_tree_columns=20, icon="xyz", + isBookmarked=True, ) @@ -73,6 +75,7 @@ class SubSkill(BaseModel): row: int = Field(description="Row of the skill in the skill tree") column: int = Field(description="Column of the skill in the skill tree") icon: str | None = Field(description="Icon of the skill") + isBookmarked: bool | None = Field(description="Indicates if the skill is bookmarked") Config = example( id="datenanalyse_mit_python", @@ -84,6 +87,7 @@ class SubSkill(BaseModel): row=1, column=2, icon="xyz", + isBookmarked=True, )