diff --git a/apps/api/src/db/courses/courses.py b/apps/api/src/db/courses/courses.py index bbc97af6..122526df 100644 --- a/apps/api/src/db/courses/courses.py +++ b/apps/api/src/db/courses/courses.py @@ -14,6 +14,7 @@ class CourseBase(SQLModel): tags: Optional[str] thumbnail_image: Optional[str] public: bool + open_to_contributors: bool class Course(CourseBase, table=True): @@ -38,7 +39,7 @@ class CourseUpdate(CourseBase): learnings: Optional[str] tags: Optional[str] public: Optional[bool] - + open_to_contributors: Optional[bool] class CourseRead(CourseBase): id: int diff --git a/apps/api/src/db/resource_authors.py b/apps/api/src/db/resource_authors.py index 3f938397..51dc3e8b 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) @@ -17,5 +23,6 @@ class ResourceAuthor(SQLModel, table=True): sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) ) authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR + authorship_status: ResourceAuthorshipStatusEnum = ResourceAuthorshipStatusEnum.ACTIVE 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..6867b783 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_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..1e74fe75 100644 --- a/apps/api/src/security/rbac/rbac.py +++ b/apps/api/src/security/rbac/rbac.py @@ -70,6 +70,8 @@ async def authorization_verify_if_user_is_author( if resource_author.user_id == int(user_id): if (resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or ( resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER + ) or ( + resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR ): return True else: 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