From 4ab8f52b0990a434a24e8c87f276a608ed29bddb Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 20 Mar 2025 13:38:10 +0100 Subject: [PATCH] feat: implement author roles in course management --- apps/api/src/db/courses/courses.py | 16 +- apps/api/src/db/resource_authors.py | 4 +- apps/api/src/routers/courses/courses.py | 2 +- apps/api/src/services/courses/courses.py | 196 ++++++++--- apps/api/src/services/explore/explore.py | 26 +- .../src/services/payments/payments_users.py | 27 +- .../course/[courseuuid]/[subpage]/page.tsx | 63 ++-- .../EditCourseContributors.tsx | 319 ++++++++++++++++++ .../Courses/CourseActions/CoursesActions.tsx | 37 +- apps/web/components/Objects/UserAvatar.tsx | 2 + apps/web/services/courses/courses.ts | 27 ++ 11 files changed, 623 insertions(+), 96 deletions(-) create mode 100644 apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx diff --git a/apps/api/src/db/courses/courses.py b/apps/api/src/db/courses/courses.py index 122526df..b50047a4 100644 --- a/apps/api/src/db/courses/courses.py +++ b/apps/api/src/db/courses/courses.py @@ -4,6 +4,15 @@ from sqlmodel import Field, SQLModel from src.db.users import UserRead from src.db.trails import TrailRead from src.db.courses.chapters import ChapterRead +from src.db.resource_authors import ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum + + +class AuthorWithRole(SQLModel): + user: UserRead + authorship: ResourceAuthorshipEnum + authorship_status: ResourceAuthorshipStatusEnum + creation_date: str + update_date: str class CourseBase(SQLModel): @@ -41,10 +50,11 @@ class CourseUpdate(CourseBase): public: Optional[bool] open_to_contributors: Optional[bool] + class CourseRead(CourseBase): id: int org_id: int = Field(default=None, foreign_key="organization.id") - authors: Optional[List[UserRead]] + authors: List[AuthorWithRole] course_uuid: str creation_date: str update_date: str @@ -58,7 +68,7 @@ class FullCourseRead(CourseBase): update_date: Optional[str] # Chapters, Activities chapters: List[ChapterRead] - authors: List[UserRead] + authors: List[AuthorWithRole] pass @@ -68,7 +78,7 @@ class FullCourseReadWithTrail(CourseBase): creation_date: Optional[str] update_date: Optional[str] org_id: int = Field(default=None, foreign_key="organization.id") - authors: List[UserRead] + authors: List[AuthorWithRole] # Chapters, Activities chapters: List[ChapterRead] # Trail diff --git a/apps/api/src/db/resource_authors.py b/apps/api/src/db/resource_authors.py index 51dc3e8b..9afa7e5a 100644 --- a/apps/api/src/db/resource_authors.py +++ b/apps/api/src/db/resource_authors.py @@ -22,7 +22,7 @@ class ResourceAuthor(SQLModel, table=True): user_id: int = Field( sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) ) - authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR - authorship_status: ResourceAuthorshipStatusEnum = ResourceAuthorshipStatusEnum.ACTIVE + authorship: ResourceAuthorshipEnum + authorship_status: ResourceAuthorshipStatusEnum creation_date: str = "" update_date: str = "" diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index 6867b783..19042524 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -294,7 +294,7 @@ async def api_get_course_contributors( return await get_course_contributors(request, course_uuid, current_user, db_session) -@router.put("/{course_uuid}/contributors/{contributor_id}") +@router.put("/{course_uuid}/contributors/{contributor_user_id}") async def api_update_course_contributor( request: Request, course_uuid: str, diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index afbd53c9..805997b5 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -10,7 +10,7 @@ from src.security.features_utils.usage import ( increase_feature_usage, ) from src.services.trail.trail import get_user_trail_with_orgid -from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum +from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum from src.db.users import PublicUser, AnonymousUser, User, UserRead from src.db.courses.courses import ( Course, @@ -18,6 +18,7 @@ from src.db.courses.courses import ( CourseRead, CourseUpdate, FullCourseReadWithTrail, + AuthorWithRole, ) from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship, @@ -48,16 +49,28 @@ async def get_course( # RBAC check await rbac_check(request, course.course_uuid, current_user, "read", db_session) - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() - # convert from User to UserRead - authors = [UserRead.model_validate(author) for author in authors] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] course = CourseRead(**course.model_dump(), authors=authors) @@ -82,16 +95,28 @@ async def get_course_by_id( # RBAC check await rbac_check(request, course.course_uuid, current_user, "read", db_session) - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() - # convert from User to UserRead - authors = [UserRead.model_validate(author) for author in authors] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] course = CourseRead(**course.model_dump(), authors=authors) @@ -123,12 +148,15 @@ async def get_course_meta( # Start async tasks concurrently tasks = [] - # Task 1: Get course authors + # Task 1: Get course authors with their roles async def get_authors(): authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) return db_session.exec(authors_statement).all() @@ -153,10 +181,19 @@ async def get_course_meta( tasks.append(get_trail()) # Run all tasks concurrently - authors_raw, chapters, trail = await asyncio.gather(*tasks) + author_results, chapters, trail = await asyncio.gather(*tasks) - # Convert authors from User to UserRead - authors = [UserRead.model_validate(author) for author in authors_raw] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] # Create course read model course_read = CourseRead(**course.model_dump(), authors=authors) @@ -167,6 +204,7 @@ async def get_course_meta( trail=trail, ) + async def get_courses_orgslug( request: Request, current_user: PublicUser | AnonymousUser, @@ -225,6 +263,9 @@ async def get_courses_orgslug( select(ResourceAuthor, User) .join(User, ResourceAuthor.user_id == User.id) # type: ignore .where(ResourceAuthor.resource_uuid.in_(course_uuids)) # type: ignore + .order_by( + ResourceAuthor.id.asc() + ) ) author_results = db_session.exec(authors_query).all() @@ -234,13 +275,23 @@ async def get_courses_orgslug( for resource_author, user in author_results: if resource_author.resource_uuid not in course_authors: course_authors[resource_author.resource_uuid] = [] - course_authors[resource_author.resource_uuid].append(UserRead.model_validate(user)) + course_authors[resource_author.resource_uuid].append( + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + ) # Create CourseRead objects with authors course_reads = [] for course in courses: - course_read = CourseRead.model_validate(course) - course_read.authors = course_authors.get(course.course_uuid, []) + course_read = CourseRead( + **course.model_dump(), + authors=course_authors.get(course.course_uuid, []) + ) course_reads.append(course_read) return course_reads @@ -306,15 +357,31 @@ async def search_courses( # Fetch authors for each course course_reads = [] for course in courses: - authors_query = ( - select(User) - .join(ResourceAuthor, ResourceAuthor.user_id == User.id) # type: ignore + # Get course authors with their roles + authors_statement = ( + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_query).all() + author_results = db_session.exec(authors_statement).all() + + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] course_read = CourseRead.model_validate(course) - course_read.authors = [UserRead.model_validate(author) for author in authors] + course_read.authors = authors course_reads.append(course_read) return course_reads @@ -368,6 +435,7 @@ async def create_course( resource_uuid=course.course_uuid, user_id=current_user.id, authorship=ResourceAuthorshipEnum.CREATOR, + authorship_status=ResourceAuthorshipStatusEnum.ACTIVE, creation_date=str(datetime.now()), update_date=str(datetime.now()), ) @@ -377,20 +445,32 @@ async def create_course( db_session.commit() db_session.refresh(resource_author) - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() + + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] # Feature usage increase_feature_usage("courses", course.org_id, db_session) - # convert from User to UserRead - authors = [UserRead.model_validate(author) for author in authors] - course = CourseRead(**course.model_dump(), authors=authors) return CourseRead.model_validate(course) @@ -444,16 +524,28 @@ async def update_course_thumbnail( db_session.commit() db_session.refresh(course) - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() - # convert from User to UserRead - authors = [UserRead.model_validate(author) for author in authors] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] course = CourseRead(**course.model_dump(), authors=authors) @@ -491,16 +583,28 @@ async def update_course( db_session.commit() db_session.refresh(course) - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() - # convert from User to UserRead - authors = [UserRead.model_validate(author) for author in authors] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] course = CourseRead(**course.model_dump(), authors=authors) diff --git a/apps/api/src/services/explore/explore.py b/apps/api/src/services/explore/explore.py index 9cc866e5..6ae2ed95 100644 --- a/apps/api/src/services/explore/explore.py +++ b/apps/api/src/services/explore/explore.py @@ -3,8 +3,10 @@ from fastapi import HTTPException, Request from sqlmodel import Session, select from sqlalchemy import text -from src.db.courses.courses import Course, CourseRead +from src.db.courses.courses import Course, CourseRead, AuthorWithRole from src.db.organizations import Organization, OrganizationRead +from src.db.users import User, UserRead +from src.db.resource_authors import ResourceAuthor def _get_sort_expression(salt: str): @@ -96,7 +98,27 @@ async def get_course_for_explore( detail="Course not found", ) - return CourseRead.model_validate(course) + # Get course authors with their roles + authors_statement = ( + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) + .where(ResourceAuthor.resource_uuid == course.course_uuid) + ) + author_results = db_session.exec(authors_statement).all() + + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] + + return CourseRead(**course.model_dump(), authors=authors) async def search_orgs_for_explore( request: Request, diff --git a/apps/api/src/services/payments/payments_users.py b/apps/api/src/services/payments/payments_users.py index 16af55a6..506070f2 100644 --- a/apps/api/src/services/payments/payments_users.py +++ b/apps/api/src/services/payments/payments_users.py @@ -1,7 +1,7 @@ from fastapi import HTTPException, Request from sqlmodel import Session, select -from typing import Any -from src.db.courses.courses import Course, CourseRead +from typing import Any, List +from src.db.courses.courses import Course, CourseRead, AuthorWithRole from src.db.payments.payments_courses import PaymentsCourse from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData from src.db.payments.payments_products import PaymentsProduct @@ -231,19 +231,28 @@ async def get_owned_courses( # Get authors for each course and convert to CourseRead course_reads = [] for course in unique_courses: - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() - # Convert authors to UserRead - author_reads = [UserRead.model_validate(author) for author in authors] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] # Create CourseRead object - course_read = CourseRead(**course.model_dump(), authors=author_reads) + course_read = CourseRead(**course.model_dump(), authors=authors) course_reads.append(course_read) return course_reads diff --git a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx index 9c27ff92..a095945a 100644 --- a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx @@ -5,11 +5,11 @@ import { CourseProvider } from '../../../../../../../../components/Contexts/Cour import Link from 'next/link' import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop' import { motion } from 'framer-motion' -import { GalleryVerticalEnd, Info, UserRoundCog } from 'lucide-react' +import { GalleryVerticalEnd, Globe, Info, UserPen, UserRoundCog, Users } from 'lucide-react' import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure' import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral' import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess' - +import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors' export type CourseOverviewParams = { orgslug: string courseuuid: string @@ -26,7 +26,7 @@ function CourseOverviewPage(props: { params: Promise }) { return (
-
+
}) {
- -
-
- -
Access
-
-
- + }) {
+ +
+
+ +
Access
+
+
+ + +
+
+ +
Contributors
+
+
+ @@ -96,6 +115,8 @@ function CourseOverviewPage(props: { params: Promise }) { {params.subpage == 'content' ? () : ('')} {params.subpage == 'general' ? () : ('')} {params.subpage == 'access' ? () : ('')} + {params.subpage == 'contributors' ? () : ('')} + diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx new file mode 100644 index 00000000..5a62ef5f --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx @@ -0,0 +1,319 @@ +import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' +import { getAPIUrl } from '@services/config/config' +import { editContributor, getCourseContributors } from '@services/courses/courses' +import { swrFetcher } from '@services/utils/ts/requests' +import { Check, ChevronDown, UserPen, Users } from 'lucide-react' +import React, { useEffect, useState } from 'react' +import toast from 'react-hot-toast' +import useSWR, { mutate } from 'swr' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import UserAvatar from '@components/Objects/UserAvatar' + +type EditCourseContributorsProps = { + orgslug: string + course_uuid?: string +} + +type ContributorRole = 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER' +type ContributorStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING' + +interface Contributor { + id: string; + user_id: string; + authorship: ContributorRole; + authorship_status: ContributorStatus; + user: { + username: string; + first_name: string; + last_name: string; + email: string; + avatar_image: string; + } +} + +function EditCourseContributors(props: EditCourseContributorsProps) { + const session = useLHSession() as any; + const access_token = session?.data?.tokens?.access_token; + const course = useCourse() as any; + const { isLoading, courseStructure } = course as any; + const dispatchCourse = useCourseDispatch() as any; + + const { data: contributors } = useSWR( + courseStructure ? `${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors` : null, + (url: string) => swrFetcher(url, access_token) + ); + + const [isOpenToContributors, setIsOpenToContributors] = useState(undefined); + + useEffect(() => { + if (!isLoading && courseStructure?.open_to_contributors !== undefined) { + setIsOpenToContributors(courseStructure.open_to_contributors); + } + }, [isLoading, courseStructure]); + + useEffect(() => { + if (!isLoading && courseStructure?.open_to_contributors !== undefined && isOpenToContributors !== undefined) { + if (isOpenToContributors !== courseStructure.open_to_contributors) { + dispatchCourse({ type: 'setIsNotSaved' }); + const updatedCourse = { + ...courseStructure, + open_to_contributors: isOpenToContributors, + }; + dispatchCourse({ type: 'setCourseStructure', payload: updatedCourse }); + } + } + }, [isLoading, isOpenToContributors, courseStructure, dispatchCourse]); + + const updateContributor = async (contributorId: string, data: { authorship?: ContributorRole; authorship_status?: ContributorStatus }) => { + try { + // Find the current contributor to get their current values + const currentContributor = contributors?.find(c => c.user_id === contributorId); + if (!currentContributor) return; + + // Don't allow editing if the user is a CREATOR + if (currentContributor.authorship === 'CREATOR') { + toast.error('Cannot modify a creator\'s role or status'); + return; + } + + // Always send both values in the request + const updatedData = { + authorship: data.authorship || currentContributor.authorship, + authorship_status: data.authorship_status || currentContributor.authorship_status + }; + + const res = await editContributor(courseStructure.course_uuid, contributorId, updatedData.authorship, updatedData.authorship_status, access_token); + if (res.status === 200 && res.data?.status === 'success') { + toast.success(res.data.detail || 'Successfully updated contributor'); + mutate(`${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors`); + } else { + toast.error(`Error: ${res.data?.detail || 'Failed to update contributor'}`); + } + } catch (error) { + toast.error('An error occurred while updating the contributor.'); + } + }; + + const RoleDropdown = ({ contributor }: { contributor: Contributor }) => ( + + + + + + {['CREATOR', 'CONTRIBUTOR', 'MAINTAINER', 'REPORTER'].map((role) => ( + updateContributor(contributor.user_id, { authorship: role as ContributorRole })} + className="justify-between" + > + {role} + {contributor.authorship === role && } + + ))} + + + ); + + const StatusDropdown = ({ contributor }: { contributor: Contributor }) => ( + + + + + + {['ACTIVE', 'INACTIVE', 'PENDING'].map((status) => ( + updateContributor(contributor.user_id, { authorship_status: status as ContributorStatus })} + className="justify-between" + > + {status} + {contributor.authorship_status === status && } + + ))} + + + ); + + const getStatusStyle = (status: ContributorStatus) => { + switch (status) { + case 'ACTIVE': + return 'bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800'; + case 'INACTIVE': + return 'bg-gray-50 text-gray-700 hover:bg-gray-100 hover:text-gray-800'; + case 'PENDING': + return 'bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800'; + default: + return 'bg-gray-50 text-gray-700 hover:bg-gray-100 hover:text-gray-800'; + } + }; + + const sortContributors = (contributors: Contributor[] | undefined) => { + if (!contributors) return []; + + return [...contributors].sort((a, b) => { + // First sort by role priority + const rolePriority: Record = { + 'CREATOR': 0, + 'MAINTAINER': 1, + 'CONTRIBUTOR': 2, + 'REPORTER': 3 + }; + + const roleDiff = rolePriority[a.authorship] - rolePriority[b.authorship]; + if (roleDiff !== 0) return roleDiff; + + // Then sort by name + const nameA = `${a.user.first_name} ${a.user.last_name}`.toLowerCase(); + const nameB = `${b.user.first_name} ${b.user.last_name}`.toLowerCase(); + return nameA.localeCompare(nameB); + }); + }; + + return ( +
+ {courseStructure && ( +
+
+
+
+

Course Contributors

+

+ Choose if you want your course to be open for contributors and manage existing contributors +

+
+
+ + {isOpenToContributors && ( +
+ Active +
+ )} +
+ +
+ Open to Contributors +
+
+ The course is open for contributors. Users can apply to become contributors and help improve the course content. +
+
+
+ } + functionToExecute={() => setIsOpenToContributors(true)} + status="info" + /> + + {!isOpenToContributors && ( +
+ Active +
+ )} +
+ +
+ Closed to Contributors +
+
+ The course is closed for contributors. Only existing contributors can modify the course content. +
+
+
+ } + functionToExecute={() => setIsOpenToContributors(false)} + status="info" + /> +
+
+

Current Contributors

+

+ Manage the current contributors of this course +

+
+
+ + + + + Name + Email + Role + Status + + + + {sortContributors(contributors)?.map((contributor) => ( + + + + + + {contributor.user.first_name} {contributor.user.last_name} + + + {contributor.user.email} + + + + + + + + + ))} + +
+
+
+ + )} + + ); +} + +export default EditCourseContributors; \ No newline at end of file diff --git a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx index 03e69c88..b81b0472 100644 --- a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx +++ b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx @@ -14,11 +14,14 @@ import CoursePaidOptions from './CoursePaidOptions' import { checkPaidAccess } from '@services/payments/payments' interface Author { - user_uuid: string - avatar_image: string - first_name: string - last_name: string - username: string + user: { + user_uuid: string + avatar_image: string + first_name: string + last_name: string + username: string + } + authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER' } interface CourseRun { @@ -55,23 +58,23 @@ const AuthorInfo = ({ author, isMobile }: { author: Author, isMobile: boolean })
Author
- {(author.first_name && author.last_name) ? ( + {(author.user.first_name && author.user.last_name) ? (
-

{`${author.first_name} ${author.last_name}`}

+

{`${author.user.first_name} ${author.user.last_name}`}

- @{author.username} + @{author.user.username}
) : (
-

@{author.username}

+

@{author.user.username}

)}
@@ -270,10 +273,20 @@ function CoursesActions({ courseuuid, orgslug, course }: CourseActionsProps) { const session = useLHSession() as any const isMobile = useMediaQuery('(max-width: 768px)') + // Sort authors by role priority + const sortedAuthors = [...course.authors].sort((a, b) => { + const rolePriority: Record = { + 'CREATOR': 0, + 'MAINTAINER': 1, + 'CONTRIBUTOR': 2, + 'REPORTER': 3 + }; + return rolePriority[a.authorship] - rolePriority[b.authorship]; + }); return (
- +
diff --git a/apps/web/components/Objects/UserAvatar.tsx b/apps/web/components/Objects/UserAvatar.tsx index ff62c3de..170639d2 100644 --- a/apps/web/components/Objects/UserAvatar.tsx +++ b/apps/web/components/Objects/UserAvatar.tsx @@ -12,6 +12,7 @@ type UserAvatarProps = { border?: 'border-2' | 'border-4' | 'border-8' borderColor?: string predefined_avatar?: 'ai' | 'empty' + backgroundColor?: 'bg-white' | 'bg-gray-100' } function UserAvatar(props: UserAvatarProps) { @@ -78,6 +79,7 @@ function UserAvatar(props: UserAvatarProps) { ${props.avatar_url && session?.data?.user?.avatar_image ? '' : 'bg-gray-700'} ${props.border ? `border ${props.border}` : ''} ${props.borderColor ?? 'border-white'} + ${props.backgroundColor ?? 'bg-gray-100'} shadow-xl aspect-square w-[${props.width ?? 50}px] diff --git a/apps/web/services/courses/courses.ts b/apps/web/services/courses/courses.ts index 562a219e..ec81f13b 100644 --- a/apps/web/services/courses/courses.ts +++ b/apps/web/services/courses/courses.ts @@ -126,3 +126,30 @@ export async function deleteCourseFromBackend(course_uuid: any, access_token:any const res = await errorHandling(result) return res } + +export async function getCourseContributors(course_uuid: string, access_token:string | null | undefined) { + const result: any = await fetch( + `${getAPIUrl()}courses/${course_uuid}/contributors`, + RequestBodyWithAuthHeader('GET', null, null,access_token || undefined) + ) + const res = await getResponseMetadata(result) + return res +} + +export async function editContributor(course_uuid: string, contributor_id: string, authorship: any, authorship_status: any, access_token:string | null | undefined) { + const result: any = await fetch( + `${getAPIUrl()}courses/${course_uuid}/contributors/${contributor_id}?authorship=${authorship}&authorship_status=${authorship_status}`, + RequestBodyWithAuthHeader('PUT', null, null,access_token || undefined) + ) + const res = await getResponseMetadata(result) + return res +} + +export async function applyForContributor(course_uuid: string, data: any, access_token:string | null | undefined) { + const result: any = await fetch( + `${getAPIUrl()}courses/${course_uuid}/apply-contributor`, + RequestBodyWithAuthHeader('POST', data, null,access_token || undefined) + ) + const res = await getResponseMetadata(result) + return res +}