feat: multi-contributors backend code

This commit is contained in:
swve 2025-03-19 13:40:48 +01:00
parent 8d2e61ff39
commit 75500bacd2
5 changed files with 245 additions and 1 deletions

View file

@ -14,6 +14,7 @@ class CourseBase(SQLModel):
tags: Optional[str] tags: Optional[str]
thumbnail_image: Optional[str] thumbnail_image: Optional[str]
public: bool public: bool
open_to_contributors: bool
class Course(CourseBase, table=True): class Course(CourseBase, table=True):
@ -38,7 +39,7 @@ class CourseUpdate(CourseBase):
learnings: Optional[str] learnings: Optional[str]
tags: Optional[str] tags: Optional[str]
public: Optional[bool] public: Optional[bool]
open_to_contributors: Optional[bool]
class CourseRead(CourseBase): class CourseRead(CourseBase):
id: int id: int

View file

@ -6,9 +6,15 @@ from sqlmodel import Field, SQLModel
class ResourceAuthorshipEnum(str, Enum): class ResourceAuthorshipEnum(str, Enum):
CREATOR = "CREATOR" CREATOR = "CREATOR"
CONTRIBUTOR = "CONTRIBUTOR"
MAINTAINER = "MAINTAINER" MAINTAINER = "MAINTAINER"
REPORTER = "REPORTER" REPORTER = "REPORTER"
class ResourceAuthorshipStatusEnum(str, Enum):
ACTIVE = "ACTIVE"
PENDING = "PENDING"
INACTIVE = "INACTIVE"
class ResourceAuthor(SQLModel, table=True): class ResourceAuthor(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=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")) sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
) )
authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR
authorship_status: ResourceAuthorshipStatusEnum = ResourceAuthorshipStatusEnum.ACTIVE
creation_date: str = "" creation_date: str = ""
update_date: str = "" update_date: str = ""

View file

@ -32,6 +32,12 @@ from src.services.courses.updates import (
get_updates_by_course_uuid, get_updates_by_course_uuid,
update_update, 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() router = APIRouter()
@ -63,6 +69,7 @@ async def api_create_course(
about=about, about=about,
learnings=learnings, learnings=learnings,
tags=tags, tags=tags,
open_to_contributors=False,
) )
return await create_course( return await create_course(
request, org_id, course, current_user, db_session, thumbnail 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) 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") @router.get("/{course_uuid}/updates")
async def api_get_course_updates( async def api_get_course_updates(
request: Request, request: Request,
@ -259,3 +279,41 @@ async def api_delete_course_update(
""" """
return await delete_update(request, courseupdate_uuid, current_user, db_session) 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
)

View file

@ -70,6 +70,8 @@ async def authorization_verify_if_user_is_author(
if resource_author.user_id == int(user_id): if resource_author.user_id == int(user_id):
if (resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or ( if (resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or (
resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER
) or (
resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR
): ):
return True return True
else: else:

View file

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