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 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

View file

@ -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]

View file

@ -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) {
<AIChatBotProvider>
<GeneralWrapperStyled>
<div className="space-y-4 pt-4">
<div className="flex space-x-6">
<div className="flex">
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}`}
>
<img
className="w-[100px] h-[57px] rounded-md drop-shadow-md"
src={`${getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.course_uuid,
course.thumbnail_image
)}`}
alt=""
/>
</Link>
</div>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Course </p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
{course.name}
</h1>
<div className="flex justify-between items-center">
<div className="flex space-x-6">
<div className="flex">
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}`}
>
<img
className="w-[100px] h-[57px] rounded-md drop-shadow-md"
src={`${getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.course_uuid,
course.thumbnail_image
)}`}
alt=""
/>
</Link>
</div>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Course </p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
{course.name}
</h1>
</div>
</div>
</div>
<ActivityIndicators
@ -136,13 +140,22 @@ function ActivityClient(props: ActivityClientProps) {
</h1>
</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 && (
<AuthenticatedClientElement checkMethod="authentication">
{activity.activity_type != 'TYPE_ASSIGNMENT' &&
<>
<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
activity={activity}
activityid={activityid}
@ -165,7 +178,6 @@ function ActivityClient(props: ActivityClientProps) {
</AssignmentSubmissionProvider>
</>
}
</AuthenticatedClientElement>
)}
</div>

View file

@ -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
)
])

View file

@ -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<boolean | null>(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) {

View file

@ -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: <b>Activity saved!</b>,
error: <b>Could not save.</b>,
})
toast.promise(
updateActivity(activity, activity.activity_uuid, access_token).then(res => {
if (!res.success) {
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 {
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
}