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..38057069 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}/updates") +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..b4460664 --- /dev/null +++ b/apps/api/src/services/courses/updates.py @@ -0,0 +1,151 @@ +from datetime import datetime +from typing import List +from uuid import uuid4 +from fastapi import HTTPException, Request, status +from sqlmodel import Session, col, 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) + .order_by(col(CourseUpdate.creation_date).desc()) + ) # https://sqlmodel.tiangolo.com/tutorial/where/#type-annotations-and-errors + updates = db_session.exec(statement).all() + + return [CourseUpdateRead(**update.model_dump()) for update in updates] 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..f0985ddd 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,8 @@ 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' +import { CourseProvider } from '@components/Contexts/CourseContext' const CourseClient = (props: any) => { const [user, setUser] = useState({}) @@ -68,9 +70,16 @@ const CourseClient = (props: any) => { ) : ( -
-

Course

-

{course.name}

+
+
+

Course

+

{course.name}

+
+
+ + + +
{props.course?.thumbnail_image && org ? ( @@ -150,13 +159,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..2b173eff --- /dev/null +++ b/apps/web/components/Objects/CourseUpdates/CourseUpdates.tsx @@ -0,0 +1,260 @@ +import { PencilLine, Rss, TentTree } from 'lucide-react' +import React, { useEffect } 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' +import { useCourse } from '@components/Contexts/CourseContext' +import useSWR, { mutate } from 'swr' +import { getAPIUrl } from '@services/config/config' +import { swrFetcher } from '@services/utils/ts/requests' +import useAdminStatus from '@components/Hooks/useAdminStatus' +import { useOrg } from '@components/Contexts/OrgContext' +import { createCourseUpdate, deleteCourseUpdate } from '@services/courses/updates' +import toast from 'react-hot-toast' +import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal' +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +dayjs.extend(relativeTime); + +function CourseUpdates() { + const course = useCourse() as any; + const { data: updates } = useSWR(`${getAPIUrl()}courses/${course?.courseStructure.course_uuid}/updates`, swrFetcher) + const [isModelOpen, setIsModelOpen] = React.useState(false) + + function handleModelOpen() { + setIsModelOpen(!isModelOpen) + } + + // if user clicks outside the model, close the model + React.useLayoutEffect(() => { + function handleClickOutside(event: any) { + console.log(event.target.id) + if (event.target.closest('.bg-white') || event.target.id === 'delete-update-button') return; + setIsModelOpen(false); + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + + return ( +
+
+
+
+ Updates + {updates && {updates.length}} +
+
+ {isModelOpen && + + } +
+ ) +} + +const UpdatesSection = () => { + const [selectedView, setSelectedView] = React.useState('list') + const isAdmin = useAdminStatus() as boolean; + return ( +
+
+
+ + Updates + +
+ {isAdmin &&
setSelectedView('new')} + className='py-2 px-4 space-x-2 items-center flex cursor-pointer text-xs font-medium hover:bg-gray-200 bg-gray-100 outline outline-1 outline-neutral-200/40'> + + New Update +
} +
+
+ {selectedView === 'list' && } + {selectedView === 'new' && } +
+
+ ) +} + +const NewUpdateForm = ({ setSelectedView }: any) => { + const org = useOrg() as any; + const course = useCourse() as any; + + 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) => { + const body = { + title: values.title, + content: values.content, + course_uuid: course.courseStructure.course_uuid, + org_id: org.id + } + const res = await createCourseUpdate(body) + if (res.status === 200) { + toast.success('Update added successfully') + setSelectedView('list') + mutate(`${getAPIUrl()}courses/${course?.courseStructure.course_uuid}/updates`) + } + else { + toast.error('Failed to add update') + } + }, + enableReinitialize: true, + }) + + useEffect(() => { + + } + , [course, org]) + + + return ( +
+
+
Test Course
+
Add new Course Update
+
+
+ + + + + + + + + + +