mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: implement author roles in course management
This commit is contained in:
parent
5f302106a9
commit
4ab8f52b09
11 changed files with 623 additions and 96 deletions
|
|
@ -4,6 +4,15 @@ from sqlmodel import Field, SQLModel
|
||||||
from src.db.users import UserRead
|
from src.db.users import UserRead
|
||||||
from src.db.trails import TrailRead
|
from src.db.trails import TrailRead
|
||||||
from src.db.courses.chapters import ChapterRead
|
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):
|
class CourseBase(SQLModel):
|
||||||
|
|
@ -41,10 +50,11 @@ class CourseUpdate(CourseBase):
|
||||||
public: Optional[bool]
|
public: Optional[bool]
|
||||||
open_to_contributors: Optional[bool]
|
open_to_contributors: Optional[bool]
|
||||||
|
|
||||||
|
|
||||||
class CourseRead(CourseBase):
|
class CourseRead(CourseBase):
|
||||||
id: int
|
id: int
|
||||||
org_id: int = Field(default=None, foreign_key="organization.id")
|
org_id: int = Field(default=None, foreign_key="organization.id")
|
||||||
authors: Optional[List[UserRead]]
|
authors: List[AuthorWithRole]
|
||||||
course_uuid: str
|
course_uuid: str
|
||||||
creation_date: str
|
creation_date: str
|
||||||
update_date: str
|
update_date: str
|
||||||
|
|
@ -58,7 +68,7 @@ class FullCourseRead(CourseBase):
|
||||||
update_date: Optional[str]
|
update_date: Optional[str]
|
||||||
# Chapters, Activities
|
# Chapters, Activities
|
||||||
chapters: List[ChapterRead]
|
chapters: List[ChapterRead]
|
||||||
authors: List[UserRead]
|
authors: List[AuthorWithRole]
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -68,7 +78,7 @@ class FullCourseReadWithTrail(CourseBase):
|
||||||
creation_date: Optional[str]
|
creation_date: Optional[str]
|
||||||
update_date: Optional[str]
|
update_date: Optional[str]
|
||||||
org_id: int = Field(default=None, foreign_key="organization.id")
|
org_id: int = Field(default=None, foreign_key="organization.id")
|
||||||
authors: List[UserRead]
|
authors: List[AuthorWithRole]
|
||||||
# Chapters, Activities
|
# Chapters, Activities
|
||||||
chapters: List[ChapterRead]
|
chapters: List[ChapterRead]
|
||||||
# Trail
|
# Trail
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class ResourceAuthor(SQLModel, table=True):
|
||||||
user_id: int = Field(
|
user_id: int = Field(
|
||||||
sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
|
sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
|
||||||
)
|
)
|
||||||
authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR
|
authorship: ResourceAuthorshipEnum
|
||||||
authorship_status: ResourceAuthorshipStatusEnum = ResourceAuthorshipStatusEnum.ACTIVE
|
authorship_status: ResourceAuthorshipStatusEnum
|
||||||
creation_date: str = ""
|
creation_date: str = ""
|
||||||
update_date: str = ""
|
update_date: str = ""
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ async def api_get_course_contributors(
|
||||||
return await get_course_contributors(request, course_uuid, current_user, db_session)
|
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(
|
async def api_update_course_contributor(
|
||||||
request: Request,
|
request: Request,
|
||||||
course_uuid: str,
|
course_uuid: str,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from src.security.features_utils.usage import (
|
||||||
increase_feature_usage,
|
increase_feature_usage,
|
||||||
)
|
)
|
||||||
from src.services.trail.trail import get_user_trail_with_orgid
|
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.users import PublicUser, AnonymousUser, User, UserRead
|
||||||
from src.db.courses.courses import (
|
from src.db.courses.courses import (
|
||||||
Course,
|
Course,
|
||||||
|
|
@ -18,6 +18,7 @@ from src.db.courses.courses import (
|
||||||
CourseRead,
|
CourseRead,
|
||||||
CourseUpdate,
|
CourseUpdate,
|
||||||
FullCourseReadWithTrail,
|
FullCourseReadWithTrail,
|
||||||
|
AuthorWithRole,
|
||||||
)
|
)
|
||||||
from src.security.rbac.rbac import (
|
from src.security.rbac.rbac import (
|
||||||
authorization_verify_based_on_roles_and_authorship,
|
authorization_verify_based_on_roles_and_authorship,
|
||||||
|
|
@ -48,16 +49,28 @@ async def get_course(
|
||||||
# RBAC check
|
# RBAC check
|
||||||
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
|
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
|
||||||
|
|
||||||
# Get course authors
|
# Get course authors with their roles
|
||||||
authors_statement = (
|
authors_statement = (
|
||||||
select(User)
|
select(ResourceAuthor, User)
|
||||||
.join(ResourceAuthor)
|
.join(User, ResourceAuthor.user_id == User.id)
|
||||||
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
.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
|
# Convert to AuthorWithRole objects
|
||||||
authors = [UserRead.model_validate(author) for author in authors]
|
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)
|
course = CourseRead(**course.model_dump(), authors=authors)
|
||||||
|
|
||||||
|
|
@ -82,16 +95,28 @@ async def get_course_by_id(
|
||||||
# RBAC check
|
# RBAC check
|
||||||
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
|
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
|
||||||
|
|
||||||
# Get course authors
|
# Get course authors with their roles
|
||||||
authors_statement = (
|
authors_statement = (
|
||||||
select(User)
|
select(ResourceAuthor, User)
|
||||||
.join(ResourceAuthor)
|
.join(User, ResourceAuthor.user_id == User.id)
|
||||||
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
.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
|
# Convert to AuthorWithRole objects
|
||||||
authors = [UserRead.model_validate(author) for author in authors]
|
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)
|
course = CourseRead(**course.model_dump(), authors=authors)
|
||||||
|
|
||||||
|
|
@ -123,12 +148,15 @@ async def get_course_meta(
|
||||||
# Start async tasks concurrently
|
# Start async tasks concurrently
|
||||||
tasks = []
|
tasks = []
|
||||||
|
|
||||||
# Task 1: Get course authors
|
# Task 1: Get course authors with their roles
|
||||||
async def get_authors():
|
async def get_authors():
|
||||||
authors_statement = (
|
authors_statement = (
|
||||||
select(User)
|
select(ResourceAuthor, User)
|
||||||
.join(ResourceAuthor)
|
.join(User, ResourceAuthor.user_id == User.id)
|
||||||
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
||||||
|
.order_by(
|
||||||
|
ResourceAuthor.id.asc()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return db_session.exec(authors_statement).all()
|
return db_session.exec(authors_statement).all()
|
||||||
|
|
||||||
|
|
@ -153,10 +181,19 @@ async def get_course_meta(
|
||||||
tasks.append(get_trail())
|
tasks.append(get_trail())
|
||||||
|
|
||||||
# Run all tasks concurrently
|
# 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
|
# Convert to AuthorWithRole objects
|
||||||
authors = [UserRead.model_validate(author) for author in authors_raw]
|
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
|
# Create course read model
|
||||||
course_read = CourseRead(**course.model_dump(), authors=authors)
|
course_read = CourseRead(**course.model_dump(), authors=authors)
|
||||||
|
|
@ -167,6 +204,7 @@ async def get_course_meta(
|
||||||
trail=trail,
|
trail=trail,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_courses_orgslug(
|
async def get_courses_orgslug(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: PublicUser | AnonymousUser,
|
current_user: PublicUser | AnonymousUser,
|
||||||
|
|
@ -225,6 +263,9 @@ async def get_courses_orgslug(
|
||||||
select(ResourceAuthor, User)
|
select(ResourceAuthor, User)
|
||||||
.join(User, ResourceAuthor.user_id == User.id) # type: ignore
|
.join(User, ResourceAuthor.user_id == User.id) # type: ignore
|
||||||
.where(ResourceAuthor.resource_uuid.in_(course_uuids)) # type: ignore
|
.where(ResourceAuthor.resource_uuid.in_(course_uuids)) # type: ignore
|
||||||
|
.order_by(
|
||||||
|
ResourceAuthor.id.asc()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
author_results = db_session.exec(authors_query).all()
|
author_results = db_session.exec(authors_query).all()
|
||||||
|
|
@ -234,13 +275,23 @@ async def get_courses_orgslug(
|
||||||
for resource_author, user in author_results:
|
for resource_author, user in author_results:
|
||||||
if resource_author.resource_uuid not in course_authors:
|
if resource_author.resource_uuid not in course_authors:
|
||||||
course_authors[resource_author.resource_uuid] = []
|
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
|
# Create CourseRead objects with authors
|
||||||
course_reads = []
|
course_reads = []
|
||||||
for course in courses:
|
for course in courses:
|
||||||
course_read = CourseRead.model_validate(course)
|
course_read = CourseRead(
|
||||||
course_read.authors = course_authors.get(course.course_uuid, [])
|
**course.model_dump(),
|
||||||
|
authors=course_authors.get(course.course_uuid, [])
|
||||||
|
)
|
||||||
course_reads.append(course_read)
|
course_reads.append(course_read)
|
||||||
|
|
||||||
return course_reads
|
return course_reads
|
||||||
|
|
@ -306,15 +357,31 @@ async def search_courses(
|
||||||
# Fetch authors for each course
|
# Fetch authors for each course
|
||||||
course_reads = []
|
course_reads = []
|
||||||
for course in courses:
|
for course in courses:
|
||||||
authors_query = (
|
# Get course authors with their roles
|
||||||
select(User)
|
authors_statement = (
|
||||||
.join(ResourceAuthor, ResourceAuthor.user_id == User.id) # type: ignore
|
select(ResourceAuthor, User)
|
||||||
|
.join(User, ResourceAuthor.user_id == User.id)
|
||||||
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
.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 = CourseRead.model_validate(course)
|
||||||
course_read.authors = [UserRead.model_validate(author) for author in authors]
|
course_read.authors = authors
|
||||||
course_reads.append(course_read)
|
course_reads.append(course_read)
|
||||||
|
|
||||||
return course_reads
|
return course_reads
|
||||||
|
|
@ -368,6 +435,7 @@ async def create_course(
|
||||||
resource_uuid=course.course_uuid,
|
resource_uuid=course.course_uuid,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
authorship=ResourceAuthorshipEnum.CREATOR,
|
authorship=ResourceAuthorshipEnum.CREATOR,
|
||||||
|
authorship_status=ResourceAuthorshipStatusEnum.ACTIVE,
|
||||||
creation_date=str(datetime.now()),
|
creation_date=str(datetime.now()),
|
||||||
update_date=str(datetime.now()),
|
update_date=str(datetime.now()),
|
||||||
)
|
)
|
||||||
|
|
@ -377,20 +445,32 @@ async def create_course(
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
db_session.refresh(resource_author)
|
db_session.refresh(resource_author)
|
||||||
|
|
||||||
# Get course authors
|
# Get course authors with their roles
|
||||||
authors_statement = (
|
authors_statement = (
|
||||||
select(User)
|
select(ResourceAuthor, User)
|
||||||
.join(ResourceAuthor)
|
.join(User, ResourceAuthor.user_id == User.id)
|
||||||
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
.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
|
# Feature usage
|
||||||
increase_feature_usage("courses", course.org_id, db_session)
|
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)
|
course = CourseRead(**course.model_dump(), authors=authors)
|
||||||
|
|
||||||
return CourseRead.model_validate(course)
|
return CourseRead.model_validate(course)
|
||||||
|
|
@ -444,16 +524,28 @@ async def update_course_thumbnail(
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
db_session.refresh(course)
|
db_session.refresh(course)
|
||||||
|
|
||||||
# Get course authors
|
# Get course authors with their roles
|
||||||
authors_statement = (
|
authors_statement = (
|
||||||
select(User)
|
select(ResourceAuthor, User)
|
||||||
.join(ResourceAuthor)
|
.join(User, ResourceAuthor.user_id == User.id)
|
||||||
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
.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
|
# Convert to AuthorWithRole objects
|
||||||
authors = [UserRead.model_validate(author) for author in authors]
|
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)
|
course = CourseRead(**course.model_dump(), authors=authors)
|
||||||
|
|
||||||
|
|
@ -491,16 +583,28 @@ async def update_course(
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
db_session.refresh(course)
|
db_session.refresh(course)
|
||||||
|
|
||||||
# Get course authors
|
# Get course authors with their roles
|
||||||
authors_statement = (
|
authors_statement = (
|
||||||
select(User)
|
select(ResourceAuthor, User)
|
||||||
.join(ResourceAuthor)
|
.join(User, ResourceAuthor.user_id == User.id)
|
||||||
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
.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
|
# Convert to AuthorWithRole objects
|
||||||
authors = [UserRead.model_validate(author) for author in authors]
|
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)
|
course = CourseRead(**course.model_dump(), authors=authors)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ from fastapi import HTTPException, Request
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from sqlalchemy import text
|
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.organizations import Organization, OrganizationRead
|
||||||
|
from src.db.users import User, UserRead
|
||||||
|
from src.db.resource_authors import ResourceAuthor
|
||||||
|
|
||||||
|
|
||||||
def _get_sort_expression(salt: str):
|
def _get_sort_expression(salt: str):
|
||||||
|
|
@ -96,7 +98,27 @@ async def get_course_for_explore(
|
||||||
detail="Course not found",
|
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(
|
async def search_orgs_for_explore(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from typing import Any
|
from typing import Any, List
|
||||||
from src.db.courses.courses import Course, CourseRead
|
from src.db.courses.courses import Course, CourseRead, AuthorWithRole
|
||||||
from src.db.payments.payments_courses import PaymentsCourse
|
from src.db.payments.payments_courses import PaymentsCourse
|
||||||
from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData
|
from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData
|
||||||
from src.db.payments.payments_products import PaymentsProduct
|
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
|
# Get authors for each course and convert to CourseRead
|
||||||
course_reads = []
|
course_reads = []
|
||||||
for course in unique_courses:
|
for course in unique_courses:
|
||||||
# Get course authors
|
# Get course authors with their roles
|
||||||
authors_statement = (
|
authors_statement = (
|
||||||
select(User)
|
select(ResourceAuthor, User)
|
||||||
.join(ResourceAuthor)
|
.join(User, ResourceAuthor.user_id == User.id)
|
||||||
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
.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
|
# Convert to AuthorWithRole objects
|
||||||
author_reads = [UserRead.model_validate(author) for author in authors]
|
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
|
# 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)
|
course_reads.append(course_read)
|
||||||
|
|
||||||
return course_reads
|
return course_reads
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import { CourseProvider } from '../../../../../../../../components/Contexts/Cour
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop'
|
import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop'
|
||||||
import { motion } from 'framer-motion'
|
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 EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure'
|
||||||
import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral'
|
import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral'
|
||||||
import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess'
|
import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess'
|
||||||
|
import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors'
|
||||||
export type CourseOverviewParams = {
|
export type CourseOverviewParams = {
|
||||||
orgslug: string
|
orgslug: string
|
||||||
courseuuid: string
|
courseuuid: string
|
||||||
|
|
@ -26,7 +26,7 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto_1fr]">
|
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto_1fr]">
|
||||||
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)}>
|
<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} />
|
<CourseOverviewTop params={params} />
|
||||||
<div className="flex space-x-3 font-black text-sm">
|
<div className="flex space-x-3 font-black text-sm">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -47,24 +47,7 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href={
|
href={
|
||||||
getUriWithOrg(params.orgslug, '') +
|
getUriWithOrg(params.orgslug, '') +
|
||||||
|
|
@ -83,6 +66,42 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,6 +115,8 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
|
||||||
{params.subpage == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')}
|
{params.subpage == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')}
|
||||||
{params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
|
{params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
|
||||||
{params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
|
{params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
|
||||||
|
{params.subpage == 'contributors' ? (<EditCourseContributors orgslug={params.orgslug} />) : ('')}
|
||||||
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</CourseProvider>
|
</CourseProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -14,11 +14,14 @@ import CoursePaidOptions from './CoursePaidOptions'
|
||||||
import { checkPaidAccess } from '@services/payments/payments'
|
import { checkPaidAccess } from '@services/payments/payments'
|
||||||
|
|
||||||
interface Author {
|
interface Author {
|
||||||
|
user: {
|
||||||
user_uuid: string
|
user_uuid: string
|
||||||
avatar_image: string
|
avatar_image: string
|
||||||
first_name: string
|
first_name: string
|
||||||
last_name: string
|
last_name: string
|
||||||
username: string
|
username: string
|
||||||
|
}
|
||||||
|
authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CourseRun {
|
interface CourseRun {
|
||||||
|
|
@ -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">
|
<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
|
<UserAvatar
|
||||||
border="border-8"
|
border="border-8"
|
||||||
avatar_url={author.avatar_image ? getUserAvatarMediaDirectory(author.user_uuid, author.avatar_image) : ''}
|
avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''}
|
||||||
predefined_avatar={author.avatar_image ? undefined : 'empty'}
|
predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
|
||||||
width={isMobile ? 60 : 100}
|
width={isMobile ? 60 : 100}
|
||||||
/>
|
/>
|
||||||
<div className="md:-space-y-2">
|
<div className="md:-space-y-2">
|
||||||
<div className="text-[12px] text-neutral-400 font-semibold">Author</div>
|
<div className="text-[12px] text-neutral-400 font-semibold">Author</div>
|
||||||
<div className="text-lg md:text-xl font-bold text-neutral-800">
|
<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">
|
<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">
|
<span className="text-xs bg-neutral-100 p-1 px-3 rounded-full text-neutral-400 font-semibold">
|
||||||
@{author.username}
|
@{author.user.username}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex space-x-2 items-center">
|
<div className="flex space-x-2 items-center">
|
||||||
<p>@{author.username}</p>
|
<p>@{author.user.username}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -270,10 +273,20 @@ function CoursesActions({ courseuuid, orgslug, course }: CourseActionsProps) {
|
||||||
const session = useLHSession() as any
|
const session = useLHSession() as any
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)')
|
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 (
|
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">
|
<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'>
|
<div className='px-3 py-2'>
|
||||||
<Actions courseuuid={courseuuid} orgslug={orgslug} course={course} />
|
<Actions courseuuid={courseuuid} orgslug={orgslug} course={course} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ type UserAvatarProps = {
|
||||||
border?: 'border-2' | 'border-4' | 'border-8'
|
border?: 'border-2' | 'border-4' | 'border-8'
|
||||||
borderColor?: string
|
borderColor?: string
|
||||||
predefined_avatar?: 'ai' | 'empty'
|
predefined_avatar?: 'ai' | 'empty'
|
||||||
|
backgroundColor?: 'bg-white' | 'bg-gray-100'
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserAvatar(props: UserAvatarProps) {
|
function UserAvatar(props: UserAvatarProps) {
|
||||||
|
|
@ -78,6 +79,7 @@ function UserAvatar(props: UserAvatarProps) {
|
||||||
${props.avatar_url && session?.data?.user?.avatar_image ? '' : 'bg-gray-700'}
|
${props.avatar_url && session?.data?.user?.avatar_image ? '' : 'bg-gray-700'}
|
||||||
${props.border ? `border ${props.border}` : ''}
|
${props.border ? `border ${props.border}` : ''}
|
||||||
${props.borderColor ?? 'border-white'}
|
${props.borderColor ?? 'border-white'}
|
||||||
|
${props.backgroundColor ?? 'bg-gray-100'}
|
||||||
shadow-xl
|
shadow-xl
|
||||||
aspect-square
|
aspect-square
|
||||||
w-[${props.width ?? 50}px]
|
w-[${props.width ?? 50}px]
|
||||||
|
|
|
||||||
|
|
@ -126,3 +126,30 @@ export async function deleteCourseFromBackend(course_uuid: any, access_token:any
|
||||||
const res = await errorHandling(result)
|
const res = await errorHandling(result)
|
||||||
return res
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue