From d1a620b2e2219bf814be2f057b80d3f220429166 Mon Sep 17 00:00:00 2001 From: swve Date: Mon, 15 Apr 2024 22:36:02 +0200 Subject: [PATCH 1/4] feat: init CourseUpdates backend CRUD --- apps/api/src/db/course_updates.py | 41 +++++++ apps/api/src/routers/courses/courses.py | 71 ++++++++++- apps/api/src/security/rbac/utils.py | 2 +- apps/api/src/services/courses/updates.py | 148 +++++++++++++++++++++++ 4 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/db/course_updates.py create mode 100644 apps/api/src/services/courses/updates.py diff --git a/apps/api/src/db/course_updates.py b/apps/api/src/db/course_updates.py new file mode 100644 index 00000000..fd1126a3 --- /dev/null +++ b/apps/api/src/db/course_updates.py @@ -0,0 +1,41 @@ +from typing import Optional +from sqlalchemy import Column, ForeignKey, Integer +from sqlmodel import Field, SQLModel + + +class CourseUpdate(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + courseupdate_uuid: str + title: str + content: str + course_id: int = Field( + sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE")) + ) + linked_activity_uuids: Optional[str] = Field(default=None) + org_id: int = Field(default=None, foreign_key="organization.id") + creation_date: str + update_date: str + +class CourseUpdateCreate(SQLModel): + title: str + content: str + linked_activity_uuids: Optional[str] = Field(default=None) + org_id: int + +class CourseUpdateRead(SQLModel): + id: int + title: str + content: str + course_id: int + courseupdate_uuid: str + linked_activity_uuids: Optional[str] = Field(default=None) + org_id: int + creation_date: str + update_date: str + +class CourseUpdateUpdate(SQLModel): + title: Optional[str] = None + content: Optional[str] = None + linked_activity_uuids: Optional[str] = Field(default=None) + + \ No newline at end of file diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index 6244c838..cd145ac3 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -2,6 +2,11 @@ from typing import List from fastapi import APIRouter, Depends, UploadFile, Form, Request from sqlmodel import Session from src.core.events.database import get_db_session +from src.db.course_updates import ( + CourseUpdateCreate, + CourseUpdateRead, + CourseUpdateUpdate, +) from src.db.users import PublicUser from src.db.courses import ( CourseCreate, @@ -19,6 +24,7 @@ from src.services.courses.courses import ( delete_course, update_course_thumbnail, ) +from src.services.courses.updates import create_update, delete_update, get_updates_by_course_uuid, update_update router = APIRouter() @@ -51,7 +57,9 @@ async def api_create_course( learnings=learnings, tags=tags, ) - return await create_course(request, org_id, course, current_user, db_session, thumbnail) + return await create_course( + request, org_id, course, current_user, db_session, thumbnail + ) @router.put("/{course_uuid}/thumbnail") @@ -145,3 +153,64 @@ async def api_delete_course( """ return await delete_course(request, course_uuid, current_user, db_session) + +@ router.get("/{course_uuid}/updates") +async def api_get_course_updates( + request: Request, + course_uuid: str, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> List[CourseUpdateRead]: + """ + Get Course Updates by course_uuid + """ + + return await get_updates_by_course_uuid(request, course_uuid, current_user, db_session) + +@router.post("/{course_uuid}/update") +async def api_create_course_update( + request: Request, + course_uuid: str, + update_object: CourseUpdateCreate, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> CourseUpdateRead: + """ + Create new Course Update + """ + + return await create_update( + request, course_uuid, update_object, current_user, db_session + ) + +@router.put("/{course_uuid}/update/{courseupdate_uuid}") +async def api_update_course_update( + request: Request, + course_uuid: str, + courseupdate_uuid: str, + update_object: CourseUpdateUpdate, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> CourseUpdateRead: + """ + Update Course Update by courseupdate_uuid + """ + + return await update_update( + request, courseupdate_uuid, update_object, current_user, db_session + ) + +@router.delete("/{course_uuid}/update/{courseupdate_uuid}") +async def api_delete_course_update( + request: Request, + course_uuid: str, + courseupdate_uuid: str, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +): + """ + Delete Course Update by courseupdate_uuid + """ + + return await delete_update(request, courseupdate_uuid, current_user, db_session) + diff --git a/apps/api/src/security/rbac/utils.py b/apps/api/src/security/rbac/utils.py index aa6560cd..0717b452 100644 --- a/apps/api/src/security/rbac/utils.py +++ b/apps/api/src/security/rbac/utils.py @@ -5,7 +5,7 @@ async def check_element_type(element_uuid): """ Check if the element is a course, a user, a house or a collection, by checking its prefix """ - if element_uuid.startswith("course_"): + if element_uuid.startswith("course_") or element_uuid.startswith("courseupdate_"): return "courses" elif element_uuid.startswith("user_"): return "users" diff --git a/apps/api/src/services/courses/updates.py b/apps/api/src/services/courses/updates.py new file mode 100644 index 00000000..5ce33e55 --- /dev/null +++ b/apps/api/src/services/courses/updates.py @@ -0,0 +1,148 @@ +from datetime import datetime +from typing import List +from uuid import uuid4 +from fastapi import HTTPException, Request, status +from sqlmodel import Session, select +from src.db.course_updates import ( + CourseUpdate, + CourseUpdateCreate, + CourseUpdateRead, + CourseUpdateUpdate, +) +from src.db.courses import Course +from src.db.organizations import Organization +from src.db.users import AnonymousUser, PublicUser +from src.services.courses.courses import rbac_check + + +async def create_update( + request: Request, + course_uuid: str, + update_object: CourseUpdateCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> CourseUpdateRead: + + # CHekc if org exists + statement_org = select(Organization).where(Organization.id == update_object.org_id) + org = db_session.exec(statement_org).first() + + if not org or org.id is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Organization does not exist" + ) + + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() + + if not course or course.id is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # Generate UUID + courseupdate_uuid = str(f"courseupdate_{uuid4()}") + + update = CourseUpdate( + **update_object.model_dump(), + course_id=course.id, + courseupdate_uuid=courseupdate_uuid, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + + db_session.add(update) + + db_session.commit() + db_session.refresh(update) + + return CourseUpdateRead(**update.model_dump()) + + +# Update Course Update +async def update_update( + request: Request, + courseupdate_uuid: str, + update_object: CourseUpdateUpdate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> CourseUpdateRead: + statement = select(CourseUpdate).where( + CourseUpdate.courseupdate_uuid == courseupdate_uuid + ) + update = db_session.exec(statement).first() + + if not update or update.id is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Update does not exist" + ) + + # RBAC check + await rbac_check( + request, update.courseupdate_uuid, current_user, "update", db_session + ) + + for key, value in update_object.model_dump().items(): + if value is not None: + setattr(update, key, value) + + + db_session.add(update) + + db_session.commit() + db_session.refresh(update) + + return CourseUpdateRead(**update.model_dump()) + + +# Delete Course Update +async def delete_update( + request: Request, + courseupdate_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + statement = select(CourseUpdate).where( + CourseUpdate.courseupdate_uuid == courseupdate_uuid + ) + update = db_session.exec(statement).first() + + if not update or update.id is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Update does not exist" + ) + + # RBAC check + await rbac_check( + request, update.courseupdate_uuid, current_user, "delete", db_session + ) + + db_session.delete(update) + db_session.commit() + + return {"message": "Update deleted successfully"} + + +# Get Course Updates by Course ID +async def get_updates_by_course_uuid( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> List[CourseUpdateRead]: + # FInd if course exists + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() + + if not course or course.id is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" + ) + + statement = select(CourseUpdate).where(CourseUpdate.course_id == course.id) + updates = db_session.exec(statement).all() + + return [CourseUpdateRead(**update.model_dump()) for update in updates] From f0e5fa925d71363ad7202742e054302c199a62a0 Mon Sep 17 00:00:00 2001 From: swve Date: Tue, 16 Apr 2024 21:54:55 +0200 Subject: [PATCH 2/4] feat: add ui skeletons for course updates --- .../(withmenu)/course/[courseuuid]/course.tsx | 116 +++++------ .../Objects/CourseUpdates/CourseUpdates.tsx | 184 ++++++++++++++++++ .../Objects/Editor/EditorWrapper.tsx | 1 + apps/web/components/Objects/Menu/Menu.tsx | 10 +- apps/web/styles/globals.css | 4 + 5 files changed, 252 insertions(+), 63 deletions(-) create mode 100644 apps/web/components/Objects/CourseUpdates/CourseUpdates.tsx diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx index 3890ee6d..c51f96cd 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx @@ -15,6 +15,7 @@ import { import { ArrowRight, Check, File, Sparkles, Video } from 'lucide-react' import { useOrg } from '@components/Contexts/OrgContext' import UserAvatar from '@components/Objects/UserAvatar' +import CourseUpdates from '@components/Objects/CourseUpdates/CourseUpdates' const CourseClient = (props: any) => { const [user, setUser] = useState({}) @@ -68,9 +69,14 @@ const CourseClient = (props: any) => { ) : ( -
-

Course

-

{course.name}

+
+
+

Course

+

{course.name}

+
+
+ +
{props.course?.thumbnail_image && org ? ( @@ -150,13 +156,13 @@ const CourseClient = (props: any) => {
{activity.activity_type === 'TYPE_DYNAMIC' && ( -
- -
- )} +
+ +
+ )} {activity.activity_type === 'TYPE_VIDEO' && (
{
{activity.activity_type === 'TYPE_DYNAMIC' && ( - <> - -
-

Page

- -
- - - )} + <> + +
+

Page

+ +
+ + + )} {activity.activity_type === 'TYPE_VIDEO' && ( <> { )} {activity.activity_type === 'TYPE_DOCUMENT' && ( - <> - -
-

Document

- -
- - - )} + <> + +
+

Document

+ +
+ + + )}
diff --git a/apps/web/components/Objects/CourseUpdates/CourseUpdates.tsx b/apps/web/components/Objects/CourseUpdates/CourseUpdates.tsx new file mode 100644 index 00000000..5b2a68db --- /dev/null +++ b/apps/web/components/Objects/CourseUpdates/CourseUpdates.tsx @@ -0,0 +1,184 @@ +import { PencilLine, Rss } from 'lucide-react' +import React from 'react' +import { motion } from 'framer-motion' +import { useFormik } from 'formik' +import * as Form from '@radix-ui/react-form' +import FormLayout, { + FormField, + FormLabelAndMessage, + Input, + Textarea, +} from '@components/StyledElements/Form/Form' + +function CourseUpdates() { + const [isModelOpen, setIsModelOpen] = React.useState(false) + + function handleModelOpen() { + setIsModelOpen(!isModelOpen) + } + + // if user clicks outside the model, close the model + React.useEffect(() => { + function handleClickOutside(event: any) { + if (event.target.closest('.bg-white') === null) { + setIsModelOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + return ( +
+
+
+
+ Updates + 5 +
+
+ {isModelOpen && + + } +
+ ) +} + +const UpdatesModel = () => { + return ( +
+
+
+ + Updates + +
+
+ + New Update +
+
+
+ +
+
+ ) +} + +const NewUpdateForm = () => { + + const validate = (values: any) => { + const errors: any = {} + + if (!values.title) { + errors.title = 'Title is required' + } + if (!values.content) { + errors.content = 'Content is required' + } + + return errors + } + const formik = useFormik({ + initialValues: { + title: '', + content: '' + }, + validate, + onSubmit: async (values) => { }, + enableReinitialize: true, + }) + return ( +
+
+
Test Course
+
Add new Course Update
+
+
+ + + + + + + + + + +