diff --git a/apps/api/migrations/versions/4a88b680263c_multi_contributors.py b/apps/api/migrations/versions/4a88b680263c_multi_contributors.py
new file mode 100644
index 00000000..4bfa6ec3
--- /dev/null
+++ b/apps/api/migrations/versions/4a88b680263c_multi_contributors.py
@@ -0,0 +1,48 @@
+"""Multi-contributors
+
+Revision ID: 4a88b680263c
+Revises: 87a621284ae4
+Create Date: 2025-03-20 11:05:24.951129
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa # noqa: F401
+import sqlmodel # noqa: F401
+from alembic_postgresql_enum import TableReference
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '4a88b680263c'
+down_revision: Union[str, None] = '87a621284ae4'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('course', sa.Column('open_to_contributors', sa.Boolean(), nullable=False, default=False, server_default='false'))
+ op.add_column('resourceauthor', sa.Column('authorship_status', postgresql.ENUM('ACTIVE', 'PENDING', 'INACTIVE', name='resourceauthorshipstatusenum', create_type=False), nullable=False, default='INACTIVE', server_default='INACTIVE'))
+ op.sync_enum_values(
+ enum_schema='public',
+ enum_name='resourceauthorshipenum',
+ new_values=['CREATOR', 'CONTRIBUTOR', 'MAINTAINER', 'REPORTER'],
+ affected_columns=[TableReference(table_schema='public', table_name='resourceauthor', column_name='authorship')],
+ enum_values_to_rename=[],
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.sync_enum_values(
+ enum_schema='public',
+ enum_name='resourceauthorshipenum',
+ new_values=['CREATOR', 'MAINTAINER', 'REPORTER'],
+ affected_columns=[TableReference(table_schema='public', table_name='resourceauthor', column_name='authorship')],
+ enum_values_to_rename=[],
+ )
+ op.drop_column('resourceauthor', 'authorship_status')
+ op.drop_column('course', 'open_to_contributors')
+ # ### end Alembic commands ###
diff --git a/apps/api/src/db/courses/courses.py b/apps/api/src/db/courses/courses.py
index bbc97af6..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):
@@ -14,6 +23,7 @@ class CourseBase(SQLModel):
tags: Optional[str]
thumbnail_image: Optional[str]
public: bool
+ open_to_contributors: bool
class Course(CourseBase, table=True):
@@ -38,12 +48,13 @@ class CourseUpdate(CourseBase):
learnings: Optional[str]
tags: Optional[str]
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
@@ -57,7 +68,7 @@ class FullCourseRead(CourseBase):
update_date: Optional[str]
# Chapters, Activities
chapters: List[ChapterRead]
- authors: List[UserRead]
+ authors: List[AuthorWithRole]
pass
@@ -67,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 3f938397..9afa7e5a 100644
--- a/apps/api/src/db/resource_authors.py
+++ b/apps/api/src/db/resource_authors.py
@@ -6,9 +6,15 @@ from sqlmodel import Field, SQLModel
class ResourceAuthorshipEnum(str, Enum):
CREATOR = "CREATOR"
+ CONTRIBUTOR = "CONTRIBUTOR"
MAINTAINER = "MAINTAINER"
REPORTER = "REPORTER"
+class ResourceAuthorshipStatusEnum(str, Enum):
+ ACTIVE = "ACTIVE"
+ PENDING = "PENDING"
+ INACTIVE = "INACTIVE"
+
class ResourceAuthor(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
@@ -16,6 +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: 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 700ae7f6..19042524 100644
--- a/apps/api/src/routers/courses/courses.py
+++ b/apps/api/src/routers/courses/courses.py
@@ -32,6 +32,12 @@ from src.services.courses.updates import (
get_updates_by_course_uuid,
update_update,
)
+from src.services.courses.contributors import (
+ apply_course_contributor,
+ update_course_contributor,
+ get_course_contributors,
+)
+from src.db.resource_authors import ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
router = APIRouter()
@@ -63,6 +69,7 @@ async def api_create_course(
about=about,
learnings=learnings,
tags=tags,
+ open_to_contributors=False,
)
return await create_course(
request, org_id, course, current_user, db_session, thumbnail
@@ -195,6 +202,19 @@ async def api_delete_course(
return await delete_course(request, course_uuid, current_user, db_session)
+@router.post("/{course_uuid}/apply-contributor")
+async def api_apply_course_contributor(
+ request: Request,
+ course_uuid: str,
+ db_session: Session = Depends(get_db_session),
+ current_user: PublicUser = Depends(get_current_user),
+):
+ """
+ Apply to be a contributor for a course
+ """
+ return await apply_course_contributor(request, course_uuid, current_user, db_session)
+
+
@router.get("/{course_uuid}/updates")
async def api_get_course_updates(
request: Request,
@@ -259,3 +279,41 @@ async def api_delete_course_update(
"""
return await delete_update(request, courseupdate_uuid, current_user, db_session)
+
+
+@router.get("/{course_uuid}/contributors")
+async def api_get_course_contributors(
+ request: Request,
+ course_uuid: str,
+ db_session: Session = Depends(get_db_session),
+ current_user: PublicUser = Depends(get_current_user),
+):
+ """
+ Get all contributors for a course
+ """
+ return await get_course_contributors(request, course_uuid, current_user, db_session)
+
+
+@router.put("/{course_uuid}/contributors/{contributor_user_id}")
+async def api_update_course_contributor(
+ request: Request,
+ course_uuid: str,
+ contributor_user_id: int,
+ authorship: ResourceAuthorshipEnum,
+ authorship_status: ResourceAuthorshipStatusEnum,
+ db_session: Session = Depends(get_db_session),
+ current_user: PublicUser = Depends(get_current_user),
+):
+ """
+ Update a course contributor's role and status
+ Only administrators can perform this action
+ """
+ return await update_course_contributor(
+ request,
+ course_uuid,
+ contributor_user_id,
+ authorship,
+ authorship_status,
+ current_user,
+ db_session
+ )
diff --git a/apps/api/src/security/rbac/rbac.py b/apps/api/src/security/rbac/rbac.py
index 66a2dc89..8a16afd3 100644
--- a/apps/api/src/security/rbac/rbac.py
+++ b/apps/api/src/security/rbac/rbac.py
@@ -4,7 +4,7 @@ from sqlalchemy import null
from sqlmodel import Session, select
from src.db.collections import Collection
from src.db.courses.courses import Course
-from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum
+from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
from src.db.roles import Role
from src.db.user_organizations import UserOrganization
from src.security.rbac.utils import check_element_type
@@ -68,9 +68,10 @@ async def authorization_verify_if_user_is_author(
if resource_author:
if resource_author.user_id == int(user_id):
- if (resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or (
- resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER
- ):
+ if ((resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or
+ (resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER) or
+ (resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR)) and \
+ resource_author.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE:
return True
else:
return False
diff --git a/apps/api/src/services/courses/activities/activities.py b/apps/api/src/services/courses/activities/activities.py
index d758358b..f20f51fb 100644
--- a/apps/api/src/services/courses/activities/activities.py
+++ b/apps/api/src/services/courses/activities/activities.py
@@ -40,7 +40,16 @@ async def create_activity(
)
# RBAC check
- await rbac_check(request, chapter.chapter_uuid, current_user, "create", db_session)
+ statement = select(Course).where(Course.id == chapter.course_id)
+ course = db_session.exec(statement).first()
+
+ if not course:
+ raise HTTPException(
+ status_code=404,
+ detail="Course not found",
+ )
+
+ await rbac_check(request, course.course_uuid, current_user, "create", db_session)
# Create Activity
activity = Activity(**activity_object.model_dump())
@@ -169,9 +178,16 @@ async def update_activity(
)
# RBAC check
- await rbac_check(
- request, activity.activity_uuid, current_user, "update", db_session
- )
+ statement = select(Course).where(Course.id == activity.course_id)
+ course = db_session.exec(statement).first()
+
+ if not course:
+ raise HTTPException(
+ status_code=404,
+ detail="Course not found",
+ )
+
+ await rbac_check(request, course.course_uuid, current_user, "update", db_session)
# Update only the fields that were passed in
for var, value in vars(activity_object).items():
@@ -203,9 +219,16 @@ async def delete_activity(
)
# RBAC check
- await rbac_check(
- request, activity.activity_uuid, current_user, "delete", db_session
- )
+ statement = select(Course).where(Course.id == activity.course_id)
+ course = db_session.exec(statement).first()
+
+ if not course:
+ raise HTTPException(
+ status_code=404,
+ detail="Course not found",
+ )
+
+ await rbac_check(request, course.course_uuid, current_user, "delete", db_session)
# Delete activity from chapter
statement = select(ChapterActivity).where(
@@ -249,7 +272,25 @@ async def get_activities(
)
# RBAC check
- await rbac_check(request, "activity_x", current_user, "read", db_session)
+ statement = select(Chapter).where(Chapter.id == coursechapter_id)
+ chapter = db_session.exec(statement).first()
+
+ if not chapter:
+ raise HTTPException(
+ status_code=404,
+ detail="Chapter not found",
+ )
+
+ statement = select(Course).where(Course.id == chapter.course_id)
+ course = db_session.exec(statement).first()
+
+ if not course:
+ raise HTTPException(
+ status_code=404,
+ detail="Course not found",
+ )
+
+ await rbac_check(request, course.course_uuid, current_user, "read", db_session)
activities = [ActivityRead.model_validate(activity) for activity in activities]
diff --git a/apps/api/src/services/courses/contributors.py b/apps/api/src/services/courses/contributors.py
new file mode 100644
index 00000000..750c2fd8
--- /dev/null
+++ b/apps/api/src/services/courses/contributors.py
@@ -0,0 +1,176 @@
+from datetime import datetime
+from fastapi import HTTPException, Request, status
+from sqlmodel import Session, select, and_
+from src.db.users import PublicUser, AnonymousUser, User, UserRead
+from src.db.courses.courses import Course
+from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
+from src.security.rbac.rbac import authorization_verify_if_user_is_anon, authorization_verify_based_on_roles_and_authorship
+from typing import List
+
+
+async def apply_course_contributor(
+ request: Request,
+ course_uuid: str,
+ current_user: PublicUser | AnonymousUser,
+ db_session: Session,
+):
+ # Verify user is not anonymous
+ await authorization_verify_if_user_is_anon(current_user.id)
+
+ # Check if course exists
+ statement = select(Course).where(Course.course_uuid == course_uuid)
+ course = db_session.exec(statement).first()
+
+ if not course:
+ raise HTTPException(
+ status_code=404,
+ detail="Course not found",
+ )
+
+ # Check if user already has any authorship role for this course
+ existing_authorship = db_session.exec(
+ select(ResourceAuthor).where(
+ and_(
+ ResourceAuthor.resource_uuid == course_uuid,
+ ResourceAuthor.user_id == current_user.id
+ )
+ )
+ ).first()
+
+ if existing_authorship:
+ raise HTTPException(
+ status_code=400,
+ detail="You already have an authorship role for this course",
+ )
+
+ # Create pending contributor application
+ resource_author = ResourceAuthor(
+ resource_uuid=course_uuid,
+ user_id=current_user.id,
+ authorship=ResourceAuthorshipEnum.CONTRIBUTOR,
+ authorship_status=ResourceAuthorshipStatusEnum.PENDING,
+ creation_date=str(datetime.now()),
+ update_date=str(datetime.now()),
+ )
+
+ db_session.add(resource_author)
+ db_session.commit()
+ db_session.refresh(resource_author)
+
+ return {
+ "detail": "Contributor application submitted successfully",
+ "status": "pending"
+ }
+
+async def update_course_contributor(
+ request: Request,
+ course_uuid: str,
+ contributor_user_id: int,
+ authorship: ResourceAuthorshipEnum,
+ authorship_status: ResourceAuthorshipStatusEnum,
+ current_user: PublicUser | AnonymousUser,
+ db_session: Session,
+):
+ """
+ Update a course contributor's role and status
+ Only administrators can perform this action
+ """
+ # Verify user is not anonymous
+ await authorization_verify_if_user_is_anon(current_user.id)
+
+ # RBAC check - verify if user has admin rights
+ authorized = await authorization_verify_based_on_roles_and_authorship(
+ request, current_user.id, "update", course_uuid, db_session
+ )
+
+ if not authorized:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="You are not authorized to update course contributors",
+ )
+
+ # Check if course exists
+ statement = select(Course).where(Course.course_uuid == course_uuid)
+ course = db_session.exec(statement).first()
+
+ if not course:
+ raise HTTPException(
+ status_code=404,
+ detail="Course not found",
+ )
+
+ # Check if the contributor exists for this course
+ existing_authorship = db_session.exec(
+ select(ResourceAuthor).where(
+ and_(
+ ResourceAuthor.resource_uuid == course_uuid,
+ ResourceAuthor.user_id == contributor_user_id
+ )
+ )
+ ).first()
+
+ if not existing_authorship:
+ raise HTTPException(
+ status_code=404,
+ detail="Contributor not found for this course",
+ )
+
+ # Don't allow changing the role of the creator
+ if existing_authorship.authorship == ResourceAuthorshipEnum.CREATOR:
+ raise HTTPException(
+ status_code=400,
+ detail="Cannot modify the role of the course creator",
+ )
+
+ # Update the contributor's role and status
+ existing_authorship.authorship = authorship
+ existing_authorship.authorship_status = authorship_status
+ existing_authorship.update_date = str(datetime.now())
+
+ db_session.add(existing_authorship)
+ db_session.commit()
+ db_session.refresh(existing_authorship)
+
+ return {
+ "detail": "Contributor updated successfully",
+ "status": "success"
+ }
+
+async def get_course_contributors(
+ request: Request,
+ course_uuid: str,
+ current_user: PublicUser | AnonymousUser,
+ db_session: Session,
+) -> List[dict]:
+ """
+ Get all contributors for a course with their user information
+ """
+ # Check if course exists
+ statement = select(Course).where(Course.course_uuid == course_uuid)
+ course = db_session.exec(statement).first()
+
+ if not course:
+ raise HTTPException(
+ status_code=404,
+ detail="Course not found",
+ )
+
+ # Get all contributors for this course with user information
+ statement = (
+ select(ResourceAuthor, User)
+ .join(User) # SQLModel will automatically join on foreign key
+ .where(ResourceAuthor.resource_uuid == course_uuid)
+ )
+ results = db_session.exec(statement).all()
+
+ return [
+ {
+ "user_id": contributor.user_id,
+ "authorship": contributor.authorship,
+ "authorship_status": contributor.authorship_status,
+ "creation_date": contributor.creation_date,
+ "update_date": contributor.update_date,
+ "user": UserRead.model_validate(user).model_dump()
+ }
+ for contributor, user in results
+ ]
\ No newline at end of file
diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py
index afbd53c9..f7405e3d 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) # type: ignore
.where(ResourceAuthor.resource_uuid == course.course_uuid)
+ .order_by(
+ ResourceAuthor.id.asc() # type: ignore
+ )
)
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..4791e766 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 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/layout.tsx b/apps/web/app/layout.tsx
index 14fedf12..1b770546 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -22,7 +22,7 @@ export default function RootLayout({
{isDevEnv ? '' : }
-
+
-
-
-
-

-
-
-
-
Course
-
- {course.name}
-
+
+
+
+
+

+
+
+
+
Course
+
+ {course.name}
+
+
-
+
{activity && activity.published == true && activity.content.paid_access != false && (
{activity.activity_type != 'TYPE_ASSIGNMENT' &&
<>
-
+ {contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && (
+
+
+ Contribute to Activity
+
+ )}
+
>
}
-
)}
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx
index 911ba54b..d8341cd7 100644
--- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx
+++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx
@@ -78,7 +78,7 @@ const ActivityPage = async (params: any) => {
fetchCourseMetadata(courseuuid, access_token),
getActivityWithAuthHeader(
activityid,
- { revalidate: 1800, tags: ['activities'] },
+ { revalidate: 0, tags: ['activities'] },
access_token || null
)
])
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx
index c3ebb3a0..c8f2ea19 100644
--- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx
+++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx
@@ -29,6 +29,8 @@ const CourseClient = (props: any) => {
const router = useRouter()
const isMobile = useMediaQuery('(max-width: 768px)')
+ console.log(course)
+
function getLearningTags() {
if (!course?.learnings) {
setLearnings([])
@@ -163,7 +165,7 @@ const CourseClient = (props: any) => {
{course.chapters.map((chapter: any) => {
return (
-
+
{chapter.name}
@@ -173,7 +175,7 @@ const CourseClient = (props: any) => {
{chapter.activities.map((activity: any) => {
return (
- <>
+
@@ -230,7 +232,7 @@ const CourseClient = (props: any) => {
{activity.activity_type ===
'TYPE_DYNAMIC' && (
- <>
+
- >
+
)}
{activity.activity_type === 'TYPE_VIDEO' && (
- <>
+
- >
+
)}
{activity.activity_type ===
'TYPE_DOCUMENT' && (
- <>
+
- >
+
)}
{activity.activity_type ===
'TYPE_ASSIGNMENT' && (
- <>
+
- >
+
)}
- >
+
)
})}
@@ -333,7 +335,7 @@ const CourseClient = (props: any) => {
{isMobile && (
-
+
)}
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx
index d0c721a4..e3216016 100644
--- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx
+++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx
@@ -72,7 +72,7 @@ const CoursePage = async (params: any) => {
// Fetch course metadata once
const course_meta = await getCourseMetadata(
params.params.courseuuid,
- { revalidate: 1800, tags: ['courses'] },
+ { revalidate: 0, tags: ['courses'] },
access_token ? access_token : null
)
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 (
+
+
+
+
+
+
@@ -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..2327492e
--- /dev/null
+++ b/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx
@@ -0,0 +1,308 @@
+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 }) => (
+
+
+
+
+
+ {['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 [];
+
+ // Find the creator and other contributors
+ const creator = contributors.find(c => c.authorship === 'CREATOR');
+ const otherContributors = contributors.filter(c => c.authorship !== 'CREATOR');
+
+ // Return array with creator at the top, followed by other contributors in their original order
+ return creator ? [creator, ...otherContributors] : otherContributors;
+ };
+
+ 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/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx
index a4b9e584..e7f99809 100644
--- a/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx
+++ b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx
@@ -9,6 +9,7 @@ import {
FilePenLine,
FileSymlink,
Globe,
+ Loader2,
Lock,
MoreVertical,
Pencil,
@@ -52,6 +53,7 @@ function ActivityElement(props: ActivitiyElementProps) {
const [selectedActivity, setSelectedActivity] = React.useState<
string | undefined
>(undefined)
+ const [isUpdatingName, setIsUpdatingName] = React.useState(false)
const activityUUID = props.activity.activity_uuid
const isMobile = useMediaQuery('(max-width: 767px)')
@@ -92,18 +94,29 @@ function ActivityElement(props: ActivitiyElementProps) {
modifiedActivity?.activityId === activityId &&
selectedActivity !== undefined
) {
+ setIsUpdatingName(true)
let modifiedActivityCopy = {
...props.activity,
name: modifiedActivity.activityName,
}
- await updateActivity(modifiedActivityCopy, activityUUID, access_token)
- mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
- await revalidateTags(['courses'], props.orgslug)
- router.refresh()
+ try {
+ await updateActivity(modifiedActivityCopy, activityUUID, access_token)
+ mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
+ await revalidateTags(['courses'], props.orgslug)
+ toast.success('Activity name updated successfully')
+ router.refresh()
+ } catch (error) {
+ toast.error('Failed to update activity name')
+ console.error('Error updating activity name:', error)
+ } finally {
+ setIsUpdatingName(false)
+ setSelectedActivity(undefined)
+ }
+ } else {
+ setSelectedActivity(undefined)
}
- setSelectedActivity(undefined)
}
return (
@@ -142,20 +155,26 @@ function ActivityElement(props: ActivitiyElementProps) {
activityName: e.target.value,
})
}
+ disabled={isUpdatingName}
/>
) : (
{props.activity.name}
)}
setSelectedActivity(props.activity.id)}
- className="text-neutral-400 hover:cursor-pointer size-3 min-w-3"
+ onClick={() => !isUpdatingName && setSelectedActivity(props.activity.id)}
+ className={`text-neutral-400 hover:cursor-pointer size-3 min-w-3 ${isUpdatingName ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
diff --git a/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx b/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx
index 702b0ec4..c559f377 100644
--- a/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx
+++ b/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx
@@ -3,7 +3,7 @@ import { useRouter } from 'next/navigation'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getUriWithoutOrg, getUriWithOrg } from '@services/config/config'
import { getProductsByCourse } from '@services/payments/products'
-import { LogIn, LogOut, ShoppingCart } from 'lucide-react'
+import { LogIn, LogOut, ShoppingCart, AlertCircle } from 'lucide-react'
import Modal from '@components/Objects/StyledElements/Modal/Modal'
import CoursePaidOptions from './CoursePaidOptions'
import { checkPaidAccess } from '@services/payments/payments'
@@ -13,11 +13,15 @@ import UserAvatar from '../../UserAvatar'
import { getUserAvatarMediaDirectory } from '@services/media/media'
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'
+ authorship_status: 'ACTIVE' | 'INACTIVE' | 'PENDING'
}
interface CourseRun {
@@ -49,11 +53,81 @@ interface CourseActionsMobileProps {
}
}
+// Component for displaying multiple authors
+const MultipleAuthors = ({ authors }: { authors: Author[] }) => {
+ const displayedAvatars = authors.slice(0, 3)
+ const remainingCount = Math.max(0, authors.length - 3)
+
+ // Avatar size for mobile
+ const avatarSize = 36
+ const borderSize = "border-2"
+
+ return (
+
+
+ {displayedAvatars.map((author, index) => (
+
+
+
+ ))}
+ {remainingCount > 0 && (
+
+
+ +{remainingCount}
+
+
+ )}
+
+
+
+
+ {authors.length > 1 ? 'Authors' : 'Author'}
+
+ {authors.length === 1 ? (
+
+ {authors[0].user.first_name && authors[0].user.last_name
+ ? `${authors[0].user.first_name} ${authors[0].user.last_name}`
+ : `@${authors[0].user.username}`}
+
+ ) : (
+
+ {authors[0].user.first_name && authors[0].user.last_name
+ ? `${authors[0].user.first_name} ${authors[0].user.last_name}`
+ : `@${authors[0].user.username}`}
+ {authors.length > 1 && ` & ${authors.length - 1} more`}
+
+ )}
+
+
+ )
+}
+
const CourseActionsMobile = ({ courseuuid, orgslug, course }: CourseActionsMobileProps) => {
const router = useRouter()
const session = useLHSession() as any
const [linkedProducts, setLinkedProducts] = useState([])
const [isLoading, setIsLoading] = useState(true)
+ const [isActionLoading, setIsActionLoading] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const [hasAccess, setHasAccess] = useState(null)
@@ -107,106 +181,141 @@ const CourseActionsMobile = ({ courseuuid, orgslug, course }: CourseActionsMobil
return
}
- if (isStarted) {
- await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
- await revalidateTags(['courses'], orgslug)
- router.refresh()
- } else {
- await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
- await revalidateTags(['courses'], orgslug)
-
- // Get the first activity from the first chapter
- const firstChapter = course.chapters?.[0]
- const firstActivity = firstChapter?.activities?.[0]
-
- if (firstActivity) {
- // Redirect to the first activity
- router.push(
- getUriWithOrg(orgslug, '') +
- `/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}`
- )
- } else {
+ setIsActionLoading(true)
+ try {
+ if (isStarted) {
+ await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
+ await revalidateTags(['courses'], orgslug)
router.refresh()
+ } else {
+ await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
+ await revalidateTags(['courses'], orgslug)
+
+ // Get the first activity from the first chapter
+ const firstChapter = course.chapters?.[0]
+ const firstActivity = firstChapter?.activities?.[0]
+
+ if (firstActivity) {
+ // Redirect to the first activity
+ router.push(
+ getUriWithOrg(orgslug, '') +
+ `/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}`
+ )
+ } else {
+ router.refresh()
+ }
}
+ } catch (error) {
+ console.error('Failed to perform course action:', error)
+ } finally {
+ setIsActionLoading(false)
}
}
if (isLoading) {
- return
+ return
}
- const author = course.authors[0]
- const authorName = author.first_name && author.last_name
- ? `${author.first_name} ${author.last_name}`
- : `@${author.username}`
+ // Filter active authors and sort by role priority
+ const sortedAuthors = [...course.authors]
+ .filter(author => author.authorship_status === 'ACTIVE')
+ .sort((a, b) => {
+ const rolePriority: Record = {
+ 'CREATOR': 0,
+ 'MAINTAINER': 1,
+ 'CONTRIBUTOR': 2,
+ 'REPORTER': 3
+ };
+ return rolePriority[a.authorship] - rolePriority[b.authorship];
+ });
return (
-
-
-
-
- Author
- {authorName}
-
-
-
-
+
+
+
+
{linkedProducts.length > 0 ? (
- hasAccess ? (
-
- ) : (
- <>
-
}
- dialogTitle="Purchase Course"
- dialogDescription="Select a payment option to access this course"
- minWidth="sm"
- />
+
+ {hasAccess ? (
+
+
+
+
You Own This Course
+
+
+ ) : (
+
+ )}
+
+ {hasAccess ? (
- >
- )
+ ) : (
+ <>
+
}
+ dialogTitle="Purchase Course"
+ dialogDescription="Select a payment option to access this course"
+ minWidth="sm"
+ />
+
+ >
+ )}
+
) : (