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 ? '' :