From b6059f8d5ca8645c5d64099043265d87128faaec Mon Sep 17 00:00:00 2001 From: swve Date: Sat, 22 Mar 2025 16:51:00 +0100 Subject: [PATCH] feat: implement contributor editing for activities --- apps/api/src/security/rbac/rbac.py | 11 ++-- .../services/courses/activities/activities.py | 57 ++++++++++++++--- .../activity/[activityid]/activity.tsx | 62 +++++++++++-------- .../activity/[activityid]/page.tsx | 2 +- .../Courses/CourseActions/CoursesActions.tsx | 39 +----------- .../Objects/Editor/EditorWrapper.tsx | 21 +++++-- apps/web/hooks/useContributorStatus.ts | 51 +++++++++++++++ apps/web/services/courses/activities.ts | 3 +- 8 files changed, 164 insertions(+), 82 deletions(-) create mode 100644 apps/web/hooks/useContributorStatus.ts diff --git a/apps/api/src/security/rbac/rbac.py b/apps/api/src/security/rbac/rbac.py index 1e74fe75..8a16afd3 100644 --- a/apps/api/src/security/rbac/rbac.py +++ b/apps/api/src/security/rbac/rbac.py @@ -4,7 +4,7 @@ from sqlalchemy import null from sqlmodel import Session, select from src.db.collections import Collection from src.db.courses.courses import Course -from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum +from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum from src.db.roles import Role from src.db.user_organizations import UserOrganization from src.security.rbac.utils import check_element_type @@ -68,11 +68,10 @@ async def authorization_verify_if_user_is_author( if resource_author: if resource_author.user_id == int(user_id): - if (resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or ( - resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER - ) or ( - resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR - ): + if ((resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or + (resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER) or + (resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR)) and \ + resource_author.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE: return True else: return False diff --git a/apps/api/src/services/courses/activities/activities.py b/apps/api/src/services/courses/activities/activities.py index d758358b..f20f51fb 100644 --- a/apps/api/src/services/courses/activities/activities.py +++ b/apps/api/src/services/courses/activities/activities.py @@ -40,7 +40,16 @@ async def create_activity( ) # RBAC check - await rbac_check(request, chapter.chapter_uuid, current_user, "create", db_session) + statement = select(Course).where(Course.id == chapter.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + await rbac_check(request, course.course_uuid, current_user, "create", db_session) # Create Activity activity = Activity(**activity_object.model_dump()) @@ -169,9 +178,16 @@ async def update_activity( ) # RBAC check - await rbac_check( - request, activity.activity_uuid, current_user, "update", db_session - ) + statement = select(Course).where(Course.id == activity.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + await rbac_check(request, course.course_uuid, current_user, "update", db_session) # Update only the fields that were passed in for var, value in vars(activity_object).items(): @@ -203,9 +219,16 @@ async def delete_activity( ) # RBAC check - await rbac_check( - request, activity.activity_uuid, current_user, "delete", db_session - ) + statement = select(Course).where(Course.id == activity.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + await rbac_check(request, course.course_uuid, current_user, "delete", db_session) # Delete activity from chapter statement = select(ChapterActivity).where( @@ -249,7 +272,25 @@ async def get_activities( ) # RBAC check - await rbac_check(request, "activity_x", current_user, "read", db_session) + statement = select(Chapter).where(Chapter.id == coursechapter_id) + chapter = db_session.exec(statement).first() + + if not chapter: + raise HTTPException( + status_code=404, + detail="Chapter not found", + ) + + statement = select(Course).where(Course.id == chapter.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + await rbac_check(request, course.course_uuid, current_user, "read", db_session) activities = [ActivityRead.model_validate(activity) for activity in activities] diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx index f3327327..1752862e 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { getAPIUrl, getUriWithOrg } from '@services/config/config' import Canva from '@components/Objects/Activities/DynamicCanva/DynamicCanva' import VideoActivity from '@components/Objects/Activities/Video/Video' -import { BookOpenCheck, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, FileText, Folder, List, Menu, MoreVertical, UserRoundPen, Video, Layers, ListFilter, ListTree, X } from 'lucide-react' +import { BookOpenCheck, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, FileText, Folder, List, Menu, MoreVertical, UserRoundPen, Video, Layers, ListFilter, ListTree, X, Edit2 } from 'lucide-react' import { markActivityAsComplete } from '@services/courses/activity' import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf' import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators' @@ -27,6 +27,7 @@ import { mutate } from 'swr' import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' import { useMediaQuery } from 'usehooks-ts' import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer' +import { useContributorStatus } from '../../../../../../../../hooks/useContributorStatus' interface ActivityClientProps { activityid: string @@ -49,6 +50,7 @@ function ActivityClient(props: ActivityClientProps) { const [bgColor, setBgColor] = React.useState('bg-white') const [assignment, setAssignment] = React.useState(null) as any; const [markStatusButtonActive, setMarkStatusButtonActive] = React.useState(false); + const { contributorStatus } = useContributorStatus(courseuuid); function getChapterNameByActivityId(course: any, activity_id: any) { @@ -90,27 +92,29 @@ function ActivityClient(props: ActivityClientProps) {
-
-
- - - -
-
-

Course

-

- {course.name} -

+
+
+
+ + + +
+
+

Course

+

+ {course.name} +

+
-
+
{activity && activity.published == true && activity.content.paid_access != false && ( {activity.activity_type != 'TYPE_ASSIGNMENT' && <> - + {contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && ( + + + Contribute to Activity + + )} + } - )}
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx index 911ba54b..d8341cd7 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx @@ -78,7 +78,7 @@ const ActivityPage = async (params: any) => { fetchCourseMetadata(courseuuid, access_token), getActivityWithAuthHeader( activityid, - { revalidate: 1800, tags: ['activities'] }, + { revalidate: 0, tags: ['activities'] }, access_token || null ) ]) diff --git a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx index fe4e7023..72b6df2e 100644 --- a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx +++ b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx @@ -12,8 +12,9 @@ import { LogIn, LogOut, ShoppingCart, AlertCircle, UserPen, ClockIcon } from 'lu import Modal from '@components/Objects/StyledElements/Modal/Modal' import CoursePaidOptions from './CoursePaidOptions' import { checkPaidAccess } from '@services/payments/payments' -import { applyForContributor, getCourseContributors } from '@services/courses/courses' +import { applyForContributor } from '@services/courses/courses' import toast from 'react-hot-toast' +import { useContributorStatus } from '../../../../hooks/useContributorStatus' interface Author { user: { @@ -192,7 +193,7 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { const [isContributeLoading, setIsContributeLoading] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) const [hasAccess, setHasAccess] = useState(null) - const [contributorStatus, setContributorStatus] = useState<'NONE' | 'PENDING' | 'ACTIVE' | 'INACTIVE'>('NONE') + const { contributorStatus } = useContributorStatus(courseuuid); const isStarted = course.trail?.runs?.some( (run) => run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id @@ -217,39 +218,6 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { fetchLinkedProducts() }, [course.id, course.org_id, session.data?.tokens?.access_token]) - // Check if the current user is already a contributor - useEffect(() => { - const checkContributorStatus = async () => { - if (!session.data?.user) return - - try { - const response = await getCourseContributors( - 'course_' + courseuuid, - session.data?.tokens?.access_token - ) - - if (response && response.data) { - const currentUser = response.data.find( - (contributor: any) => contributor.user_id === session.data.user.id - ) - - if (currentUser) { - setContributorStatus(currentUser.authorship_status as 'PENDING' | 'ACTIVE' | 'INACTIVE') - } else { - setContributorStatus('NONE') - } - } - } catch (error) { - console.error('Failed to check contributor status:', error) - toast.error('Failed to check contributor status. Please try again later.') - } - } - - if (session.data?.user) { - checkContributorStatus() - } - }, [courseuuid, session.data?.tokens?.access_token, session.data?.user]) - useEffect(() => { const checkAccess = async () => { if (!session.data?.user) return @@ -337,7 +305,6 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { } await applyForContributor('course_' + courseuuid, data, session.data?.tokens?.access_token) - setContributorStatus('PENDING') await revalidateTags(['courses'], orgslug) toast.success('Your application to contribute has been submitted successfully', { id: loadingToast }) } catch (error) { diff --git a/apps/web/components/Objects/Editor/EditorWrapper.tsx b/apps/web/components/Objects/Editor/EditorWrapper.tsx index 500401d4..a48a5c87 100644 --- a/apps/web/components/Objects/Editor/EditorWrapper.tsx +++ b/apps/web/components/Objects/Editor/EditorWrapper.tsx @@ -22,11 +22,22 @@ function EditorWrapper(props: EditorWrapperProps): JSX.Element { let activity = props.activity activity.content = content - toast.promise(updateActivity(activity, activity.activity_uuid, access_token), { - loading: 'Saving...', - success: Activity saved!, - error: Could not save., - }) + toast.promise( + updateActivity(activity, activity.activity_uuid, access_token).then(res => { + if (!res.success) { + throw res; + } + return res; + }), + { + loading: 'Saving...', + success: () => Activity saved!, + error: (err) => { + const errorMessage = err?.data?.detail || err?.data?.message || `Error ${err?.status}: Could not save`; + return {errorMessage}; + }, + } + ) } diff --git a/apps/web/hooks/useContributorStatus.ts b/apps/web/hooks/useContributorStatus.ts new file mode 100644 index 00000000..a1370ed1 --- /dev/null +++ b/apps/web/hooks/useContributorStatus.ts @@ -0,0 +1,51 @@ +import { useState, useEffect } from 'react'; +import { getCourseContributors } from '@services/courses/courses'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import toast from 'react-hot-toast'; + +export type ContributorStatus = 'NONE' | 'PENDING' | 'ACTIVE' | 'INACTIVE'; + +export function useContributorStatus(courseUuid: string) { + const session = useLHSession() as any; + const [contributorStatus, setContributorStatus] = useState('NONE'); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const checkContributorStatus = async () => { + if (!session.data?.user) { + setIsLoading(false); + return; + } + + try { + const response = await getCourseContributors( + 'course_' + courseUuid, + session.data?.tokens?.access_token + ); + + if (response && response.data) { + const currentUser = response.data.find( + (contributor: any) => contributor.user_id === session.data.user.id + ); + + if (currentUser) { + setContributorStatus(currentUser.authorship_status as ContributorStatus); + } else { + setContributorStatus('NONE'); + } + } + } catch (error) { + console.error('Failed to check contributor status:', error); + toast.error('Failed to check contributor status'); + } finally { + setIsLoading(false); + } + }; + + if (session.data?.user) { + checkContributorStatus(); + } + }, [courseUuid, session.data?.tokens?.access_token, session.data?.user]); + + return { contributorStatus, isLoading }; +} \ No newline at end of file diff --git a/apps/web/services/courses/activities.ts b/apps/web/services/courses/activities.ts index 89cf748f..f49324e4 100644 --- a/apps/web/services/courses/activities.ts +++ b/apps/web/services/courses/activities.ts @@ -2,6 +2,7 @@ import { getAPIUrl } from '@services/config/config' import { RequestBodyFormWithAuthHeader, RequestBodyWithAuthHeader, + getResponseMetadata, } from '@services/utils/ts/requests' export async function createActivity( @@ -130,6 +131,6 @@ export async function updateActivity( `${getAPIUrl()}activities/${activity_uuid}`, RequestBodyWithAuthHeader('PUT', data, null, access_token) ) - const res = await result.json() + const res = await getResponseMetadata(result) return res }