Skip to content

Commit

Permalink
Add bookmarking endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
Lauritz-Tieste committed Jul 17, 2024
1 parent a3eec97 commit c3a095b
Show file tree
Hide file tree
Showing 10 changed files with 259 additions and 16 deletions.
31 changes: 31 additions & 0 deletions alembic/versions/2024_07_06_2009-4f7559aa1483_.py
Original file line number Diff line number Diff line change
@@ -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")
4 changes: 2 additions & 2 deletions api/endpoints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

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


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)
Expand Down
111 changes: 111 additions & 0 deletions api/endpoints/bookmarks.py
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 31 additions & 13 deletions api/endpoints/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions api/exceptions/bookmarks.py
Original file line number Diff line number Diff line change
@@ -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."
6 changes: 6 additions & 0 deletions api/exceptions/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions api/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .bookmarks import SubSkillBookmark
from .course_access import CourseAccess
from .last_watch import LastWatch
from .lecture_progress import LectureProgress
Expand All @@ -18,4 +19,5 @@
"SubSkillDependency",
"TreeSettings",
"XP",
"SubSkillBookmark",
]
42 changes: 42 additions & 0 deletions api/models/bookmarks.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 15 additions & 1 deletion api/models/sub_skill.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
}
4 changes: 4 additions & 0 deletions api/schemas/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -26,6 +27,7 @@ class RootSkill(BaseModel):
sub_tree_rows=20,
sub_tree_columns=20,
icon="xyz",
isBookmarked=True,
)


Expand Down Expand Up @@ -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",
Expand All @@ -84,6 +87,7 @@ class SubSkill(BaseModel):
row=1,
column=2,
icon="xyz",
isBookmarked=True,
)


Expand Down

0 comments on commit c3a095b

Please sign in to comment.