feat: implement contributor editing for activities

This commit is contained in:
swve 2025-03-22 16:51:00 +01:00
parent 95c3550c42
commit b6059f8d5c
8 changed files with 164 additions and 82 deletions

View file

@ -4,7 +4,7 @@ from sqlalchemy import null
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.collections import Collection from src.db.collections import Collection
from src.db.courses.courses import Course 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.roles import Role
from src.db.user_organizations import UserOrganization from src.db.user_organizations import UserOrganization
from src.security.rbac.utils import check_element_type 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:
if resource_author.user_id == int(user_id): if resource_author.user_id == int(user_id):
if (resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or ( if ((resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or
resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER (resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER) or
) or ( (resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR)) and \
resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR resource_author.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE:
):
return True return True
else: else:
return False return False

View file

@ -40,7 +40,16 @@ async def create_activity(
) )
# RBAC check # 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 # Create Activity
activity = Activity(**activity_object.model_dump()) activity = Activity(**activity_object.model_dump())
@ -169,9 +178,16 @@ async def update_activity(
) )
# RBAC check # RBAC check
await rbac_check( statement = select(Course).where(Course.id == activity.course_id)
request, activity.activity_uuid, current_user, "update", db_session 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 # Update only the fields that were passed in
for var, value in vars(activity_object).items(): for var, value in vars(activity_object).items():
@ -203,9 +219,16 @@ async def delete_activity(
) )
# RBAC check # RBAC check
await rbac_check( statement = select(Course).where(Course.id == activity.course_id)
request, activity.activity_uuid, current_user, "delete", db_session 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 # Delete activity from chapter
statement = select(ChapterActivity).where( statement = select(ChapterActivity).where(
@ -249,7 +272,25 @@ async def get_activities(
) )
# RBAC check # 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] activities = [ActivityRead.model_validate(activity) for activity in activities]

View file

@ -3,7 +3,7 @@ import Link from 'next/link'
import { getAPIUrl, getUriWithOrg } from '@services/config/config' import { getAPIUrl, getUriWithOrg } from '@services/config/config'
import Canva from '@components/Objects/Activities/DynamicCanva/DynamicCanva' import Canva from '@components/Objects/Activities/DynamicCanva/DynamicCanva'
import VideoActivity from '@components/Objects/Activities/Video/Video' 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 { markActivityAsComplete } from '@services/courses/activity'
import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf' import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf'
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators' import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
@ -27,6 +27,7 @@ import { mutate } from 'swr'
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { useMediaQuery } from 'usehooks-ts' import { useMediaQuery } from 'usehooks-ts'
import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer' import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer'
import { useContributorStatus } from '../../../../../../../../hooks/useContributorStatus'
interface ActivityClientProps { interface ActivityClientProps {
activityid: string activityid: string
@ -49,6 +50,7 @@ function ActivityClient(props: ActivityClientProps) {
const [bgColor, setBgColor] = React.useState('bg-white') const [bgColor, setBgColor] = React.useState('bg-white')
const [assignment, setAssignment] = React.useState(null) as any; const [assignment, setAssignment] = React.useState(null) as any;
const [markStatusButtonActive, setMarkStatusButtonActive] = React.useState(false); const [markStatusButtonActive, setMarkStatusButtonActive] = React.useState(false);
const { contributorStatus } = useContributorStatus(courseuuid);
function getChapterNameByActivityId(course: any, activity_id: any) { function getChapterNameByActivityId(course: any, activity_id: any) {
@ -90,27 +92,29 @@ function ActivityClient(props: ActivityClientProps) {
<AIChatBotProvider> <AIChatBotProvider>
<GeneralWrapperStyled> <GeneralWrapperStyled>
<div className="space-y-4 pt-4"> <div className="space-y-4 pt-4">
<div className="flex space-x-6"> <div className="flex justify-between items-center">
<div className="flex"> <div className="flex space-x-6">
<Link <div className="flex">
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}`} <Link
> href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}`}
<img >
className="w-[100px] h-[57px] rounded-md drop-shadow-md" <img
src={`${getCourseThumbnailMediaDirectory( className="w-[100px] h-[57px] rounded-md drop-shadow-md"
org?.org_uuid, src={`${getCourseThumbnailMediaDirectory(
course.course_uuid, org?.org_uuid,
course.thumbnail_image course.course_uuid,
)}`} course.thumbnail_image
alt="" )}`}
/> alt=""
</Link> />
</div> </Link>
<div className="flex flex-col -space-y-1"> </div>
<p className="font-bold text-gray-700 text-md">Course </p> <div className="flex flex-col -space-y-1">
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase"> <p className="font-bold text-gray-700 text-md">Course </p>
{course.name} <h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
</h1> {course.name}
</h1>
</div>
</div> </div>
</div> </div>
<ActivityIndicators <ActivityIndicators
@ -136,13 +140,22 @@ function ActivityClient(props: ActivityClientProps) {
</h1> </h1>
</div> </div>
</div> </div>
<div className="flex space-x-1 items-center"> <div className="flex space-x-2 items-center">
{activity && activity.published == true && activity.content.paid_access != false && ( {activity && activity.published == true && activity.content.paid_access != false && (
<AuthenticatedClientElement checkMethod="authentication"> <AuthenticatedClientElement checkMethod="authentication">
{activity.activity_type != 'TYPE_ASSIGNMENT' && {activity.activity_type != 'TYPE_ASSIGNMENT' &&
<> <>
<AIActivityAsk activity={activity} /> <AIActivityAsk activity={activity} />
<MoreVertical size={17} className="text-gray-300 " /> {contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && (
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}/activity/${activityid}/edit`}
className="bg-emerald-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out"
>
<Edit2 size={17} />
<span className="text-xs font-bold">Contribute to Activity</span>
</Link>
)}
<MoreVertical size={17} className="text-gray-300" />
<MarkStatus <MarkStatus
activity={activity} activity={activity}
activityid={activityid} activityid={activityid}
@ -165,7 +178,6 @@ function ActivityClient(props: ActivityClientProps) {
</AssignmentSubmissionProvider> </AssignmentSubmissionProvider>
</> </>
} }
</AuthenticatedClientElement> </AuthenticatedClientElement>
)} )}
</div> </div>

View file

@ -78,7 +78,7 @@ const ActivityPage = async (params: any) => {
fetchCourseMetadata(courseuuid, access_token), fetchCourseMetadata(courseuuid, access_token),
getActivityWithAuthHeader( getActivityWithAuthHeader(
activityid, activityid,
{ revalidate: 1800, tags: ['activities'] }, { revalidate: 0, tags: ['activities'] },
access_token || null access_token || null
) )
]) ])

View file

@ -12,8 +12,9 @@ import { LogIn, LogOut, ShoppingCart, AlertCircle, UserPen, ClockIcon } from 'lu
import Modal from '@components/Objects/StyledElements/Modal/Modal' import Modal from '@components/Objects/StyledElements/Modal/Modal'
import CoursePaidOptions from './CoursePaidOptions' import CoursePaidOptions from './CoursePaidOptions'
import { checkPaidAccess } from '@services/payments/payments' 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 toast from 'react-hot-toast'
import { useContributorStatus } from '../../../../hooks/useContributorStatus'
interface Author { interface Author {
user: { user: {
@ -192,7 +193,7 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
const [isContributeLoading, setIsContributeLoading] = useState(false) const [isContributeLoading, setIsContributeLoading] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [hasAccess, setHasAccess] = useState<boolean | null>(null) const [hasAccess, setHasAccess] = useState<boolean | null>(null)
const [contributorStatus, setContributorStatus] = useState<'NONE' | 'PENDING' | 'ACTIVE' | 'INACTIVE'>('NONE') const { contributorStatus } = useContributorStatus(courseuuid);
const isStarted = course.trail?.runs?.some( const isStarted = course.trail?.runs?.some(
(run) => run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id (run) => run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id
@ -217,39 +218,6 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
fetchLinkedProducts() fetchLinkedProducts()
}, [course.id, course.org_id, session.data?.tokens?.access_token]) }, [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(() => { useEffect(() => {
const checkAccess = async () => { const checkAccess = async () => {
if (!session.data?.user) return 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) await applyForContributor('course_' + courseuuid, data, session.data?.tokens?.access_token)
setContributorStatus('PENDING')
await revalidateTags(['courses'], orgslug) await revalidateTags(['courses'], orgslug)
toast.success('Your application to contribute has been submitted successfully', { id: loadingToast }) toast.success('Your application to contribute has been submitted successfully', { id: loadingToast })
} catch (error) { } catch (error) {

View file

@ -22,11 +22,22 @@ function EditorWrapper(props: EditorWrapperProps): JSX.Element {
let activity = props.activity let activity = props.activity
activity.content = content activity.content = content
toast.promise(updateActivity(activity, activity.activity_uuid, access_token), { toast.promise(
loading: 'Saving...', updateActivity(activity, activity.activity_uuid, access_token).then(res => {
success: <b>Activity saved!</b>, if (!res.success) {
error: <b>Could not save.</b>, throw res;
}) }
return res;
}),
{
loading: 'Saving...',
success: () => <b>Activity saved!</b>,
error: (err) => {
const errorMessage = err?.data?.detail || err?.data?.message || `Error ${err?.status}: Could not save`;
return <b>{errorMessage}</b>;
},
}
)
} }

View file

@ -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<ContributorStatus>('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 };
}

View file

@ -2,6 +2,7 @@ import { getAPIUrl } from '@services/config/config'
import { import {
RequestBodyFormWithAuthHeader, RequestBodyFormWithAuthHeader,
RequestBodyWithAuthHeader, RequestBodyWithAuthHeader,
getResponseMetadata,
} from '@services/utils/ts/requests' } from '@services/utils/ts/requests'
export async function createActivity( export async function createActivity(
@ -130,6 +131,6 @@ export async function updateActivity(
`${getAPIUrl()}activities/${activity_uuid}`, `${getAPIUrl()}activities/${activity_uuid}`,
RequestBodyWithAuthHeader('PUT', data, null, access_token) RequestBodyWithAuthHeader('PUT', data, null, access_token)
) )
const res = await result.json() const res = await getResponseMetadata(result)
return res return res
} }