diff --git a/apps/api/migrations/versions/a5afa69dd917_activity_details.py b/apps/api/migrations/versions/a5afa69dd917_activity_details.py new file mode 100644 index 00000000..78531b4b --- /dev/null +++ b/apps/api/migrations/versions/a5afa69dd917_activity_details.py @@ -0,0 +1,31 @@ +"""Activity Details + +Revision ID: a5afa69dd917 +Revises: adb944cc8bec +Create Date: 2025-04-22 16:04:58.028488 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa # noqa: F401 +import sqlmodel # noqa: F401 + + +# revision identifiers, used by Alembic. +revision: str = 'a5afa69dd917' +down_revision: Union[str, None] = 'adb944cc8bec' +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('activity', sa.Column('details', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('activity', 'details') + # ### end Alembic commands ### diff --git a/apps/api/src/db/courses/activities.py b/apps/api/src/db/courses/activities.py index 50ec31c8..1cb327ea 100644 --- a/apps/api/src/db/courses/activities.py +++ b/apps/api/src/db/courses/activities.py @@ -32,6 +32,7 @@ class ActivityBase(SQLModel): activity_type: ActivityTypeEnum activity_sub_type: ActivitySubTypeEnum content: dict = Field(default={}, sa_column=Column(JSON)) + details: Optional[dict] = Field(default=None, sa_column=Column(JSON)) published: bool = False @@ -53,6 +54,7 @@ class ActivityCreate(ActivityBase): chapter_id: int activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM + details: dict = Field(default={}, sa_column=Column(JSON)) pass @@ -61,6 +63,7 @@ class ActivityUpdate(ActivityBase): content: dict = Field(default={}, sa_column=Column(JSON)) activity_type: Optional[ActivityTypeEnum] activity_sub_type: Optional[ActivitySubTypeEnum] + details: Optional[dict] = Field(default=None, sa_column=Column(JSON)) published_version: Optional[int] version: Optional[int] @@ -72,4 +75,5 @@ class ActivityRead(ActivityBase): activity_uuid: str creation_date: str update_date: str + details: Optional[dict] = Field(default=None, sa_column=Column(JSON)) pass diff --git a/apps/api/src/routers/courses/activities/activities.py b/apps/api/src/routers/courses/activities/activities.py index 3b5bc84a..7ac20cf5 100644 --- a/apps/api/src/routers/courses/activities/activities.py +++ b/apps/api/src/routers/courses/activities/activities.py @@ -113,6 +113,7 @@ async def api_create_video_activity( request: Request, name: str = Form(), chapter_id: str = Form(), + details: str = Form(default="{}"), current_user: PublicUser = Depends(get_current_user), video_file: UploadFile | None = None, db_session=Depends(get_db_session), @@ -127,6 +128,7 @@ async def api_create_video_activity( current_user, db_session, video_file, + details, ) diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index 19042524..f2271983 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -126,6 +126,7 @@ async def api_get_course_by_id( async def api_get_course_meta( request: Request, course_uuid: str, + with_unpublished_activities: bool = False, db_session: Session = Depends(get_db_session), current_user: PublicUser = Depends(get_current_user), ) -> FullCourseReadWithTrail: @@ -133,7 +134,7 @@ async def api_get_course_meta( Get single Course Metadata (chapters, activities) by course_uuid """ return await get_course_meta( - request, course_uuid, current_user=current_user, db_session=db_session + request, course_uuid, with_unpublished_activities, current_user=current_user, db_session=db_session ) diff --git a/apps/api/src/routers/trail.py b/apps/api/src/routers/trail.py index cb9b3d48..1dedc12a 100644 --- a/apps/api/src/routers/trail.py +++ b/apps/api/src/routers/trail.py @@ -10,6 +10,7 @@ from src.services.trail.trail import ( get_user_trails, get_user_trail_with_orgid, remove_course_from_trail, + remove_activity_from_trail, ) @@ -95,3 +96,16 @@ async def api_add_activity_to_trail( return await add_activity_to_trail( request, user, activity_uuid, db_session ) + + +@router.delete("/remove_activity/{activity_uuid}") +async def api_remove_activity_from_trail( + request: Request, + activity_uuid: str, + user=Depends(get_current_user), + db_session=Depends(get_db_session), +) -> TrailRead: + """ + Remove Activity from trail + """ + return await remove_activity_from_trail(request, user, activity_uuid, db_session) diff --git a/apps/api/src/services/courses/activities/activities.py b/apps/api/src/services/courses/activities/activities.py index f20f51fb..42ad1f05 100644 --- a/apps/api/src/services/courses/activities/activities.py +++ b/apps/api/src/services/courses/activities/activities.py @@ -260,15 +260,21 @@ async def get_activities( current_user: PublicUser | AnonymousUser, db_session: Session, ) -> list[ActivityRead]: - statement = select(ChapterActivity).where( - ChapterActivity.chapter_id == coursechapter_id + # Get activities that are published and belong to the chapter + statement = ( + select(Activity) + .join(ChapterActivity) + .where( + ChapterActivity.chapter_id == coursechapter_id, + Activity.published == True + ) ) activities = db_session.exec(statement).all() if not activities: raise HTTPException( status_code=404, - detail="No activities found", + detail="No published activities found", ) # RBAC check diff --git a/apps/api/src/services/courses/activities/video.py b/apps/api/src/services/courses/activities/video.py index da428865..71408033 100644 --- a/apps/api/src/services/courses/activities/video.py +++ b/apps/api/src/services/courses/activities/video.py @@ -1,4 +1,5 @@ from typing import Literal +import json from src.db.courses.courses import Course from src.db.organizations import Organization @@ -31,6 +32,7 @@ async def create_video_activity( current_user: PublicUser, db_session: Session, video_file: UploadFile | None = None, + details: str = "{}", ): # RBAC check await rbac_check(request, "activity_x", current_user, "create", db_session) @@ -39,6 +41,9 @@ async def create_video_activity( statement = select(Chapter).where(Chapter.id == chapter_id) chapter = db_session.exec(statement).first() + # convert details to dict + details = json.loads(details) + if not chapter: raise HTTPException( status_code=404, @@ -99,6 +104,7 @@ async def create_video_activity( "filename": "video." + video_format, "activity_uuid": activity_uuid, }, + details=details, version=1, creation_date=str(datetime.now()), update_date=str(datetime.now()), @@ -144,6 +150,7 @@ class ExternalVideo(BaseModel): uri: str type: Literal["youtube", "vimeo"] chapter_id: str + details: str = "{}" class ExternalVideoInDB(BaseModel): @@ -181,6 +188,9 @@ async def create_external_video_activity( # generate activity_uuid activity_uuid = str(f"activity_{uuid4()}") + # convert details to dict + details = json.loads(data.details) + activity_object = Activity( name=data.name, activity_type=ActivityTypeEnum.TYPE_VIDEO, @@ -194,6 +204,7 @@ async def create_external_video_activity( "type": data.type, "activity_uuid": activity_uuid, }, + details=details, version=1, creation_date=str(datetime.now()), update_date=str(datetime.now()), diff --git a/apps/api/src/services/courses/chapters.py b/apps/api/src/services/courses/chapters.py index 97afcf92..4a30bb3d 100644 --- a/apps/api/src/services/courses/chapters.py +++ b/apps/api/src/services/courses/chapters.py @@ -214,6 +214,7 @@ async def get_course_chapters( course_id: int, db_session: Session, current_user: PublicUser | AnonymousUser, + with_unpublished_activities: bool, page: int = 1, limit: int = 10, ) -> List[ChapterRead]: @@ -249,7 +250,7 @@ async def get_course_chapters( for chapter_activity in chapter_activities: statement = ( select(Activity) - .where(Activity.id == chapter_activity.activity_id) + .where(Activity.id == chapter_activity.activity_id, with_unpublished_activities or Activity.published == True) .distinct(Activity.id) ) activity = db_session.exec(statement).first() diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 53d11fd2..444a8e4e 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -126,6 +126,7 @@ async def get_course_by_id( async def get_course_meta( request: Request, course_uuid: str, + with_unpublished_activities: bool, current_user: PublicUser | AnonymousUser, db_session: Session, ) -> FullCourseReadWithTrail: @@ -165,7 +166,7 @@ async def get_course_meta( # Ensure course.id is not None if course.id is None: return [] - return await get_course_chapters(request, course.id, db_session, current_user) + return await get_course_chapters(request, course.id, db_session, current_user, with_unpublished_activities) # Task 3: Get user trail (only for authenticated users) async def get_trail(): diff --git a/apps/api/src/services/trail/trail.py b/apps/api/src/services/trail/trail.py index 7daebdea..30a5bffc 100644 --- a/apps/api/src/services/trail/trail.py +++ b/apps/api/src/services/trail/trail.py @@ -282,6 +282,79 @@ async def add_activity_to_trail( return trail_read +async def remove_activity_from_trail( + request: Request, + user: PublicUser, + activity_uuid: str, + db_session: Session, +) -> TrailRead: + # Look for the activity + statement = select(Activity).where(Activity.activity_uuid == activity_uuid) + activity = db_session.exec(statement).first() + + if not activity: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Activity not found" + ) + + statement = select(Course).where(Course.id == activity.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Course not found" + ) + + statement = select(Trail).where( + Trail.org_id == course.org_id, Trail.user_id == user.id + ) + trail = db_session.exec(statement).first() + + if not trail: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Trail not found" + ) + + # Delete the trail step for this activity + statement = select(TrailStep).where( + TrailStep.activity_id == activity.id, + TrailStep.user_id == user.id, + TrailStep.trail_id == trail.id + ) + trail_step = db_session.exec(statement).first() + + if trail_step: + db_session.delete(trail_step) + db_session.commit() + + # Get updated trail data + statement = select(TrailRun).where(TrailRun.trail_id == trail.id, TrailRun.user_id == user.id) + trail_runs = db_session.exec(statement).all() + + trail_runs = [ + TrailRunRead(**trail_run.__dict__, course={}, steps=[], course_total_steps=0) + for trail_run in trail_runs + ] + + for trail_run in trail_runs: + statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id, TrailStep.user_id == user.id) + trail_steps = db_session.exec(statement).all() + + trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps] + trail_run.steps = trail_steps + + for trail_step in trail_steps: + statement = select(Course).where(Course.id == trail_step.course_id) + course = db_session.exec(statement).first() + trail_step.data = dict(course=course) + + trail_read = TrailRead( + **trail.model_dump(), + runs=trail_runs, + ) + + return trail_read + async def add_course_to_trail( request: Request, diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx index 6ae554ff..885d4ab4 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx @@ -4,7 +4,7 @@ import { getAPIUrl, getUriWithOrg } from '@services/config/config' import Canva from '@components/Objects/Activities/DynamicCanva/DynamicCanva' import VideoActivity from '@components/Objects/Activities/Video/Video' import { BookOpenCheck, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, FileText, Folder, List, Menu, MoreVertical, UserRoundPen, Video, Layers, ListFilter, ListTree, X, Edit2 } from 'lucide-react' -import { markActivityAsComplete } from '@services/courses/activity' +import { markActivityAsComplete, unmarkActivityAsComplete } from '@services/courses/activity' import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf' import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators' import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' @@ -28,6 +28,11 @@ import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationMo import { useMediaQuery } from 'usehooks-ts' import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer' import { useContributorStatus } from '../../../../../../../../hooks/useContributorStatus' +import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' +import ActivityNavigation from '@components/Pages/Activity/ActivityNavigation' +import ActivityChapterDropdown from '@components/Pages/Activity/ActivityChapterDropdown' +import FixedActivitySecondaryBar from '@components/Pages/Activity/FixedActivitySecondaryBar' +import CourseEndView from '@components/Pages/Activity/CourseEndView' interface ActivityClientProps { activityid: string @@ -91,159 +96,175 @@ function ActivityClient(props: ActivityClientProps) { -
-
-
-
- - - -
-
-

Course

-

- {course.name} -

-
-
-
- - -
-
- -
-

- Chapter : {getChapterNameByActivityId(course, activity.id)} -

-

- {activity.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 - - )} - - - - } - {activity.activity_type == 'TYPE_ASSIGNMENT' && - <> - - - +
+
+
+
+
+ + - - - } - - )} -
-
- {activity && activity.published == false && ( -
-
-

- This activity is not published yet -

-
-
- )} + +
+
+

Course

+

+ {course.name} +

+
+
+
- {activity && activity.published == true && ( - <> - {activity.content.paid_access == false ? ( - - ) : ( -
- {/* Activity Types */} -
- {activity.activity_type == 'TYPE_DYNAMIC' && ( - - )} - {activity.activity_type == 'TYPE_VIDEO' && ( - - )} - {activity.activity_type == 'TYPE_DOCUMENT' && ( - - )} - {activity.activity_type == 'TYPE_ASSIGNMENT' && ( -
- {assignment ? ( - - - - - - - - ) : ( -
+ + +
+
+ +
+

+ Chapter : {getChapterNameByActivityId(course, activity.id)} +

+

+ {activity.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 + + )} + + + )} -
+ {activity.activity_type == 'TYPE_ASSIGNMENT' && ( + <> + + + + + + )} + )}
+
+ {activity && activity.published == false && ( +
+
+

+ This activity is not published yet +

+
+
)} - - )} - - {/* Activity Navigation */} - {activity && activity.published == true && activity.content.paid_access != false && ( - - )} - - {
} -
+ + {activity && activity.published == true && ( + <> + {activity.content.paid_access == false ? ( + + ) : ( +
+ {/* Activity Types */} +
+ {activity.activity_type == 'TYPE_DYNAMIC' && ( + + )} + {activity.activity_type == 'TYPE_VIDEO' && ( + + )} + {activity.activity_type == 'TYPE_DOCUMENT' && ( + + )} + {activity.activity_type == 'TYPE_ASSIGNMENT' && ( +
+ {assignment ? ( + + + + + + + + ) : ( +
+ )} +
+ )} +
+
+ )} + + )} + + + {/* Fixed Activity Secondary Bar */} + {activity && activity.published == true && activity.content.paid_access != false && ( + + )} + +
+
+
+ )} @@ -262,10 +283,73 @@ export function MarkStatus(props: { const isMobile = useMediaQuery('(max-width: 768px)') const [isLoading, setIsLoading] = React.useState(false); + const areAllActivitiesCompleted = () => { + const run = props.course.trail.runs.find( + (run: any) => run.course_id == props.course.id + ); + if (!run) return false; + + let totalActivities = 0; + let completedActivities = 0; + + // Count all activities and completed activities + props.course.chapters.forEach((chapter: any) => { + chapter.activities.forEach((activity: any) => { + totalActivities++; + const isCompleted = run.steps.find( + (step: any) => step.activity_id === activity.id && step.complete === true + ); + if (isCompleted) { + completedActivities++; + } + }); + }); + + console.log('Total activities:', totalActivities); + console.log('Completed activities:', completedActivities); + console.log('All completed?', completedActivities >= totalActivities - 1); + + // We check for totalActivities - 1 because the current activity completion + // hasn't been counted yet (it's in progress) + return completedActivities >= totalActivities - 1; + }; + async function markActivityAsCompleteFront() { try { + // Check if this will be the last activity to complete + const willCompleteAll = areAllActivitiesCompleted(); + console.log('Will complete all?', willCompleteAll); + setIsLoading(true); - const trail = await markActivityAsComplete( + await markActivityAsComplete( + props.orgslug, + props.course.course_uuid, + props.activity.activity_uuid, + session.data?.tokens?.access_token + ); + + // Mutate the course data + await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`); + + if (willCompleteAll) { + console.log('Redirecting to end page...'); + const cleanCourseUuid = props.course.course_uuid.replace('course_', ''); + router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/end`); + } else { + router.refresh(); + } + } catch (error) { + console.error('Error marking activity as complete:', error); + toast.error('Failed to mark activity as complete'); + } finally { + setIsLoading(false); + } + } + + async function unmarkActivityAsCompleteFront() { + try { + setIsLoading(true); + const trail = await unmarkActivityAsComplete( props.orgslug, props.course.course_uuid, props.activity.activity_uuid, @@ -276,7 +360,7 @@ export function MarkStatus(props: { await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`); router.refresh(); } catch (error) { - toast.error('Failed to mark activity as complete'); + toast.error('Failed to unmark activity as complete'); } finally { setIsLoading(false); } @@ -296,36 +380,121 @@ export function MarkStatus(props: { return ( <> {isActivityCompleted() ? ( -
- - - {' '} - Complete -
- ) : ( -
- {isLoading ? ( -
- - - - -
- ) : ( +
+
- - )}{' '} - {!isMobile && {isLoading ? 'Marking...' : 'Mark as complete'}} + {' '} + Complete +
+ + + {isLoading ? ( +
+ + + + +
+ ) : ( + + )} +
+ } + functionToExecute={unmarkActivityAsCompleteFront} + status="warning" + /> + + +
+ ) : ( +
+
+ {isLoading ? ( +
+ + + + +
+ ) : ( + + + + )}{' '} + {!isMobile && {isLoading ? 'Marking...' : 'Mark as complete'}} +
+
)} ) } +function NextActivityButton({ course, currentActivityId, orgslug }: { course: any, currentActivityId: string, orgslug: string }) { + const router = useRouter(); + const isMobile = useMediaQuery('(max-width: 768px)'); + + const findNextActivity = () => { + let allActivities: any[] = []; + let currentIndex = -1; + + // Flatten all activities from all chapters + course.chapters.forEach((chapter: any) => { + chapter.activities.forEach((activity: any) => { + const cleanActivityUuid = activity.activity_uuid?.replace('activity_', ''); + allActivities.push({ + ...activity, + cleanUuid: cleanActivityUuid, + chapterName: chapter.name + }); + + // Check if this is the current activity + if (activity.id === currentActivityId) { + currentIndex = allActivities.length - 1; + } + }); + }); + + // Get next activity + return currentIndex < allActivities.length - 1 ? allActivities[currentIndex + 1] : null; + }; + + const nextActivity = findNextActivity(); + + if (!nextActivity) return null; + + const navigateToActivity = () => { + const cleanCourseUuid = course.course_uuid?.replace('course_', ''); + router.push(getUriWithOrg(orgslug, '') + `/course/${cleanCourseUuid}/activity/${nextActivity.cleanUuid}`); + }; + + return ( + +
+ {!isMobile && Next} + +
+
+ ); +} + function AssignmentTools(props: { activity: any activityid: string @@ -442,335 +611,4 @@ function AssignmentTools(props: { return null } -function ActivityChapterDropdown(props: { - course: any - currentActivityId: string - orgslug: string -}): React.ReactNode { - const [isOpen, setIsOpen] = React.useState(false); - const dropdownRef = React.useRef(null); - const isMobile = useMediaQuery('(max-width: 768px)'); - - // Close dropdown when clicking outside - React.useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - const toggleDropdown = () => { - setIsOpen(!isOpen); - }; - - // Function to get the appropriate icon for activity type - const getActivityTypeIcon = (activityType: string) => { - switch (activityType) { - case 'TYPE_VIDEO': - return