feat: implement author roles in course management

This commit is contained in:
swve 2025-03-20 13:38:10 +01:00
parent 5f302106a9
commit 4ab8f52b09
11 changed files with 623 additions and 96 deletions

View file

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

View file

@ -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 = ""

View file

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

View file

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

View file

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

View file

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

View file

@ -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<CourseOverviewParams> }) {
return (
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto_1fr]">
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)}>
<div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]">
<div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 nice-shadow">
<CourseOverviewTop params={params} />
<div className="flex space-x-3 font-black text-sm">
<Link
@ -47,24 +47,7 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/access`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'access'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<UserRoundCog size={16} />
<div>Access</div>
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
@ -83,6 +66,42 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/access`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'access'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Globe size={16} />
<div>Access</div>
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/contributors`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'contributors'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<UserPen size={16} />
<div>Contributors</div>
</div>
</div>
</Link>
</div>
</div>
@ -96,6 +115,8 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
{params.subpage == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')}
{params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
{params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
{params.subpage == 'contributors' ? (<EditCourseContributors orgslug={params.orgslug} />) : ('')}
</motion.div>
</CourseProvider>
</div>

View file

@ -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<Contributor[]>(
courseStructure ? `${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors` : null,
(url: string) => swrFetcher(url, access_token)
);
const [isOpenToContributors, setIsOpenToContributors] = useState<boolean | undefined>(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 }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-[200px] justify-between"
disabled={contributor.authorship === 'CREATOR'}
>
{contributor.authorship}
<ChevronDown className="ml-2 h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{['CREATOR', 'CONTRIBUTOR', 'MAINTAINER', 'REPORTER'].map((role) => (
<DropdownMenuItem
key={role}
onClick={() => updateContributor(contributor.user_id, { authorship: role as ContributorRole })}
className="justify-between"
>
{role}
{contributor.authorship === role && <Check className="ml-2 h-4 w-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
const StatusDropdown = ({ contributor }: { contributor: Contributor }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className={`w-[200px] justify-between ${getStatusStyle(contributor.authorship_status)}`}
disabled={contributor.authorship === 'CREATOR'}
>
{contributor.authorship_status}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{['ACTIVE', 'INACTIVE', 'PENDING'].map((status) => (
<DropdownMenuItem
key={status}
onClick={() => updateContributor(contributor.user_id, { authorship_status: status as ContributorStatus })}
className="justify-between"
>
{status}
{contributor.authorship_status === status && <Check className="ml-2 h-4 w-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
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<ContributorRole, number> = {
'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 (
<div>
{courseStructure && (
<div>
<div className="h-6"></div>
<div className="mx-4 sm:mx-10 bg-white rounded-xl shadow-xs px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
<h1 className="font-bold text-lg sm:text-xl text-gray-800">Course Contributors</h1>
<h2 className="text-gray-500 text-xs sm:text-sm">
Choose if you want your course to be open for contributors and manage existing contributors
</h2>
</div>
<div className="flex flex-col sm:flex-row sm:space-x-2 space-y-2 sm:space-y-0 mx-auto mb-3">
<ConfirmationModal
confirmationButtonText="Open to Contributors"
confirmationMessage="Are you sure you want to open this course to contributors?"
dialogTitle="Open to Contributors?"
dialogTrigger={
<div className="w-full h-[200px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 transition-all">
{isOpenToContributors && (
<div className="bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg">
Active
</div>
)}
<div className="flex flex-col space-y-1 justify-center items-center h-full p-2 sm:p-4">
<UserPen className="text-slate-400" size={32} />
<div className="text-xl sm:text-2xl text-slate-700 font-bold">
Open to Contributors
</div>
<div className="text-gray-400 text-sm sm:text-md tracking-tight w-full sm:w-[500px] leading-5 text-center">
The course is open for contributors. Users can apply to become contributors and help improve the course content.
</div>
</div>
</div>
}
functionToExecute={() => setIsOpenToContributors(true)}
status="info"
/>
<ConfirmationModal
confirmationButtonText="Close to Contributors"
confirmationMessage="Are you sure you want to close this course to contributors?"
dialogTitle="Close to Contributors?"
dialogTrigger={
<div className="w-full h-[200px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 transition-all">
{!isOpenToContributors && (
<div className="bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg">
Active
</div>
)}
<div className="flex flex-col space-y-1 justify-center items-center h-full p-2 sm:p-4">
<Users className="text-slate-400" size={32} />
<div className="text-xl sm:text-2xl text-slate-700 font-bold">
Closed to Contributors
</div>
<div className="text-gray-400 text-sm sm:text-md tracking-tight w-full sm:w-[500px] leading-5 text-center">
The course is closed for contributors. Only existing contributors can modify the course content.
</div>
</div>
</div>
}
functionToExecute={() => setIsOpenToContributors(false)}
status="info"
/>
</div>
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
<h1 className="font-bold text-lg sm:text-xl text-gray-800">Current Contributors</h1>
<h2 className="text-gray-500 text-xs sm:text-sm">
Manage the current contributors of this course
</h2>
</div>
<div className="max-h-[600px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]"></TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortContributors(contributors)?.map((contributor) => (
<TableRow key={contributor.id}>
<TableCell>
<UserAvatar
width={30}
border='border-2'
avatar_url={contributor.user.avatar_image}
rounded="rounded"
predefined_avatar={contributor.user.avatar_image === '' ? 'empty' : undefined}
/>
</TableCell>
<TableCell className="font-medium">
{contributor.user.first_name} {contributor.user.last_name}
</TableCell>
<TableCell className="text-gray-500">
{contributor.user.email}
</TableCell>
<TableCell>
<RoleDropdown contributor={contributor} />
</TableCell>
<TableCell>
<StatusDropdown contributor={contributor} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
)}
</div>
);
}
export default EditCourseContributors;

View file

@ -14,12 +14,15 @@ import CoursePaidOptions from './CoursePaidOptions'
import { checkPaidAccess } from '@services/payments/payments'
interface Author {
user: {
user_uuid: string
avatar_image: string
first_name: string
last_name: string
username: string
}
authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
}
interface CourseRun {
status: string
@ -55,23 +58,23 @@ const AuthorInfo = ({ author, isMobile }: { author: Author, isMobile: boolean })
<div className="flex flex-row md:flex-col mx-auto space-y-0 md:space-y-3 space-x-4 md:space-x-0 px-2 py-2 items-center">
<UserAvatar
border="border-8"
avatar_url={author.avatar_image ? getUserAvatarMediaDirectory(author.user_uuid, author.avatar_image) : ''}
predefined_avatar={author.avatar_image ? undefined : 'empty'}
avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''}
predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
width={isMobile ? 60 : 100}
/>
<div className="md:-space-y-2">
<div className="text-[12px] text-neutral-400 font-semibold">Author</div>
<div className="text-lg md:text-xl font-bold text-neutral-800">
{(author.first_name && author.last_name) ? (
{(author.user.first_name && author.user.last_name) ? (
<div className="flex space-x-2 items-center">
<p>{`${author.first_name} ${author.last_name}`}</p>
<p>{`${author.user.first_name} ${author.user.last_name}`}</p>
<span className="text-xs bg-neutral-100 p-1 px-3 rounded-full text-neutral-400 font-semibold">
@{author.username}
@{author.user.username}
</span>
</div>
) : (
<div className="flex space-x-2 items-center">
<p>@{author.username}</p>
<p>@{author.user.username}</p>
</div>
)}
</div>
@ -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<string, number> = {
'CREATOR': 0,
'MAINTAINER': 1,
'CONTRIBUTOR': 2,
'REPORTER': 3
};
return rolePriority[a.authorship] - rolePriority[b.authorship];
});
return (
<div className=" space-y-3 antialiased flex flex-col p-3 py-5 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
<AuthorInfo author={course.authors[0]} isMobile={isMobile} />
<AuthorInfo author={sortedAuthors[0]} isMobile={isMobile} />
<div className='px-3 py-2'>
<Actions courseuuid={courseuuid} orgslug={orgslug} course={course} />
</div>

View file

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

View file

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