diff --git a/apps/api/src/routers/users.py b/apps/api/src/routers/users.py index b7b6780b..b78b5fc0 100644 --- a/apps/api/src/routers/users.py +++ b/apps/api/src/routers/users.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Literal, List from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from pydantic import EmailStr from sqlmodel import Session @@ -9,6 +9,7 @@ from src.services.users.password_reset import ( from src.services.orgs.orgs import get_org_join_mechanism from src.security.auth import get_current_user from src.core.events.database import get_db_session +from src.db.courses.courses import CourseRead from src.db.users import ( PublicUser, @@ -33,6 +34,7 @@ from src.services.users.users import ( update_user_avatar, update_user_password, ) +from src.services.courses.courses import get_user_courses router = APIRouter() @@ -277,3 +279,26 @@ async def api_delete_user( Delete User """ return await delete_user_by_id(request, db_session, current_user, user_id) + + +@router.get("/{user_id}/courses", response_model=List[CourseRead], tags=["users"]) +async def api_get_user_courses( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + user_id: int, + page: int = 1, + limit: int = 10, +) -> List[CourseRead]: + """ + Get courses made or contributed by a user. + """ + return await get_user_courses( + request=request, + current_user=current_user, + user_id=user_id, + db_session=db_session, + page=page, + limit=limit, + ) diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index f7405e3d..192df13a 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -638,7 +638,88 @@ async def delete_course( return {"detail": "Course deleted"} - +async def get_user_courses( + request: Request, + current_user: PublicUser | AnonymousUser, + user_id: int, + db_session: Session, + page: int = 1, + limit: int = 10, +) -> List[CourseRead]: + # Verify user is not anonymous + await authorization_verify_if_user_is_anon(current_user.id) + + # Get all resource authors for the user + statement = select(ResourceAuthor).where( + and_( + ResourceAuthor.user_id == user_id, + ResourceAuthor.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE + ) + ) + resource_authors = db_session.exec(statement).all() + + # Extract course UUIDs from resource authors + course_uuids = [author.resource_uuid for author in resource_authors] + + if not course_uuids: + return [] + + # Get courses with the extracted UUIDs + statement = select(Course).where(Course.course_uuid.in_(course_uuids)) + + # Apply pagination + statement = statement.offset((page - 1) * limit).limit(limit) + + courses = db_session.exec(statement).all() + + # Convert to CourseRead objects + result = [] + for course in courses: + # Get authors for the course + authors_statement = select(ResourceAuthor).where( + ResourceAuthor.resource_uuid == course.course_uuid + ) + authors = db_session.exec(authors_statement).all() + + # Convert authors to AuthorWithRole objects + authors_with_role = [] + for author in authors: + # Get user for the author + user_statement = select(User).where(User.id == author.user_id) + user = db_session.exec(user_statement).first() + + if user: + authors_with_role.append( + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=author.authorship, + authorship_status=author.authorship_status, + creation_date=author.creation_date, + update_date=author.update_date, + ) + ) + + # Create CourseRead object + course_read = CourseRead( + id=course.id, + org_id=course.org_id, + name=course.name, + description=course.description, + about=course.about, + learnings=course.learnings, + tags=course.tags, + thumbnail_image=course.thumbnail_image, + public=course.public, + open_to_contributors=course.open_to_contributors, + course_uuid=course.course_uuid, + creation_date=course.creation_date, + update_date=course.update_date, + authors=authors_with_role, + ) + + result.append(course_read) + + return result ## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index c02b6a23..5c19b786 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -153,13 +153,13 @@ async def create_user_with_invite( user = await create_user(request, db_session, current_user, user_object, org_id) # Check if invite code contains UserGroup - if inviteCode.get("usergroup_id"): + if inviteCode.get("usergroup_id"): # type: ignore # Add user to UserGroup await add_users_to_usergroup( request, db_session, InternalUser(id=0), - int(inviteCode.get("usergroup_id")), # Convert to int since usergroup_id is expected to be int + int(inviteCode.get("usergroup_id")), # type: ignore / Convert to int since usergroup_id is expected to be int str(user.id), ) diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/UserProfileClient.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/UserProfileClient.tsx index f648ddcc..cda99126 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/UserProfileClient.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/user/[username]/UserProfileClient.tsx @@ -15,9 +15,14 @@ import { Users, Calendar, Lightbulb, - X + X, + ExternalLink } from 'lucide-react' import { getUserAvatarMediaDirectory } from '@services/media/media' +import { getCoursesByUser } from '@services/users/users' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { Button } from "@components/ui/button" +import CourseThumbnailLanding from '@components/Objects/Thumbnails/CourseThumbnailLanding' interface UserProfileClientProps { userData: any; @@ -67,7 +72,31 @@ const ImageModal: React.FC<{ }; function UserProfileClient({ userData, profile }: UserProfileClientProps) { + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token const [selectedImage, setSelectedImage] = React.useState<{ url: string; caption?: string } | null>(null); + const [userCourses, setUserCourses] = React.useState([]); + const [isLoadingCourses, setIsLoadingCourses] = React.useState(false); + + React.useEffect(() => { + const fetchUserCourses = async () => { + if (userData.id && access_token) { + try { + setIsLoadingCourses(true); + const coursesData = await getCoursesByUser(userData.id, access_token); + if (coursesData.data) { + setUserCourses(coursesData.data); + } + } catch (error) { + console.error('Error fetching user courses:', error); + } finally { + setIsLoadingCourses(false); + } + } + }; + + fetchUserCourses(); + }, [userData.id, access_token]); const IconComponent = ({ iconName }: { iconName: string }) => { const IconElement = ICON_MAP[iconName as keyof typeof ICON_MAP] @@ -273,6 +302,31 @@ function UserProfileClient({ userData, profile }: UserProfileClientProps) { ))} )} + + {section.type === 'courses' && ( +
+ {isLoadingCourses ? ( +
+
+
+ ) : userCourses.length > 0 ? ( +
+ {userCourses.map((course) => ( +
+ +
+ ))} +
+ ) : ( +
+ No courses found +
+ )} +
+ )} ))} diff --git a/apps/web/components/Dashboard/Pages/UserAccount/UserProfile/UserProfileBuilder.tsx b/apps/web/components/Dashboard/Pages/UserAccount/UserProfile/UserProfileBuilder.tsx index 71b7f918..34798d10 100644 --- a/apps/web/components/Dashboard/Pages/UserAccount/UserProfile/UserProfileBuilder.tsx +++ b/apps/web/components/Dashboard/Pages/UserAccount/UserProfile/UserProfileBuilder.tsx @@ -1,6 +1,6 @@ import React from 'react' import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd' -import { Plus, Trash2, GripVertical, ImageIcon, Link as LinkIcon, Award, ArrowRight, Edit, TextIcon, Briefcase, GraduationCap, Upload, MapPin } from 'lucide-react' +import { Plus, Trash2, GripVertical, ImageIcon, Link as LinkIcon, Award, ArrowRight, Edit, TextIcon, Briefcase, GraduationCap, Upload, MapPin, BookOpen } from 'lucide-react' import { Input } from "@components/ui/input" import { Textarea } from "@components/ui/textarea" import { Label } from "@components/ui/label" @@ -48,6 +48,11 @@ const SECTION_TYPES = { icon: MapPin, label: 'Affiliation', description: 'Add organizational affiliations' + }, + 'courses': { + icon: BookOpen, + label: 'Courses', + description: 'Display authored courses' } } as const @@ -94,6 +99,14 @@ interface ProfileAffiliation { logoUrl: string; } +interface Course { + id: string; + title: string; + description: string; + thumbnail?: string; + status: string; +} + interface BaseSection { id: string; type: keyof typeof SECTION_TYPES; @@ -135,6 +148,11 @@ interface AffiliationSection extends BaseSection { affiliations: ProfileAffiliation[]; } +interface CoursesSection extends BaseSection { + type: 'courses'; + // No need to store courses as they will be fetched from API +} + type ProfileSection = | ImageGallerySection | TextSection @@ -142,7 +160,8 @@ type ProfileSection = | SkillsSection | ExperienceSection | EducationSection - | AffiliationSection; + | AffiliationSection + | CoursesSection; interface ProfileData { sections: ProfileSection[]; @@ -196,7 +215,7 @@ const UserProfileBuilder = () => { const baseSection = { id: `section-${Date.now()}`, type, - title: `New ${SECTION_TYPES[type].label} Section` + title: `${SECTION_TYPES[type].label} Section` } switch (type) { @@ -242,6 +261,11 @@ const UserProfileBuilder = () => { type: 'affiliation', affiliations: [] } + case 'courses': + return { + ...baseSection, + type: 'courses' + } } } @@ -507,6 +531,8 @@ const SectionEditor: React.FC = ({ section, onChange }) => { return case 'affiliation': return + case 'courses': + return default: return
Unknown section type
} @@ -1285,4 +1311,35 @@ const AffiliationEditor: React.FC<{ ) } +const CoursesEditor: React.FC<{ + section: CoursesSection; + onChange: (section: CoursesSection) => void; +}> = ({ section, onChange }) => { + return ( +
+
+ +

Courses

+
+ +
+ {/* Title */} +
+ + onChange({ ...section, title: e.target.value })} + placeholder="Enter section title" + /> +
+ +
+ Your authored courses will be automatically displayed in this section. +
+
+
+ ) +} + export default UserProfileBuilder \ No newline at end of file diff --git a/apps/web/services/users/users.ts b/apps/web/services/users/users.ts index 4b0c5fee..e0d33c0c 100644 --- a/apps/web/services/users/users.ts +++ b/apps/web/services/users/users.ts @@ -25,6 +25,14 @@ export async function getUserByUsername(username: string, access_token?: string) return res } +export async function getCoursesByUser(user_id: string, access_token?: string) { + const result = await fetch( + `${getAPIUrl()}users/${user_id}/courses`, + access_token ? RequestBodyWithAuthHeader('GET', null, null, access_token) : RequestBody('GET', null, null) + ) + const res = await getResponseMetadata(result) + return res +} export async function updateUserAvatar( user_uuid: any, avatar_file: any,