mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19: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 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
|
||||||
|
|
|
||||||
|
|
@ -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,10 +178,17 @@ 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():
|
||||||
if value is not None:
|
if value is not None:
|
||||||
|
|
@ -203,10 +219,17 @@ 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(
|
||||||
ChapterActivity.activity_id == activity.id
|
ChapterActivity.activity_id == activity.id
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,6 +92,7 @@ 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 justify-between items-center">
|
||||||
<div className="flex space-x-6">
|
<div className="flex space-x-6">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -113,6 +116,7 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<ActivityIndicators
|
<ActivityIndicators
|
||||||
course_uuid={courseuuid}
|
course_uuid={courseuuid}
|
||||||
current_activity={activityid}
|
current_activity={activityid}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
updateActivity(activity, activity.activity_uuid, access_token).then(res => {
|
||||||
|
if (!res.success) {
|
||||||
|
throw res;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}),
|
||||||
|
{
|
||||||
loading: 'Saving...',
|
loading: 'Saving...',
|
||||||
success: <b>Activity saved!</b>,
|
success: () => <b>Activity saved!</b>,
|
||||||
error: <b>Could not save.</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 {
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue