mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: multi-contributors backend code
This commit is contained in:
parent
8d2e61ff39
commit
75500bacd2
5 changed files with 245 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
176
apps/api/src/services/courses/contributors.py
Normal file
176
apps/api/src/services/courses/contributors.py
Normal 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
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue