mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-18 20:09:25 +00:00
feat: implement contributor editing for activities
This commit is contained in:
parent
95c3550c42
commit
b6059f8d5c
8 changed files with 164 additions and 82 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
51
apps/web/hooks/useContributorStatus.ts
Normal file
51
apps/web/hooks/useContributorStatus.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue