feat: Add User Courses in Profile

This commit is contained in:
swve 2025-04-06 12:12:01 +02:00
parent 31c27bb70e
commit b1aec48947
6 changed files with 233 additions and 8 deletions

View file

@ -1,4 +1,4 @@
from typing import Literal from typing import Literal, List
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from pydantic import EmailStr from pydantic import EmailStr
from sqlmodel import Session 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.services.orgs.orgs import get_org_join_mechanism
from src.security.auth import get_current_user from src.security.auth import get_current_user
from src.core.events.database import get_db_session from src.core.events.database import get_db_session
from src.db.courses.courses import CourseRead
from src.db.users import ( from src.db.users import (
PublicUser, PublicUser,
@ -33,6 +34,7 @@ from src.services.users.users import (
update_user_avatar, update_user_avatar,
update_user_password, update_user_password,
) )
from src.services.courses.courses import get_user_courses
router = APIRouter() router = APIRouter()
@ -277,3 +279,26 @@ async def api_delete_user(
Delete User Delete User
""" """
return await delete_user_by_id(request, db_session, current_user, user_id) 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,
)

View file

@ -638,7 +638,88 @@ async def delete_course(
return {"detail": "Course deleted"} 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 ## ## 🔒 RBAC Utils ##

View file

@ -153,13 +153,13 @@ async def create_user_with_invite(
user = await create_user(request, db_session, current_user, user_object, org_id) user = await create_user(request, db_session, current_user, user_object, org_id)
# Check if invite code contains UserGroup # Check if invite code contains UserGroup
if inviteCode.get("usergroup_id"): if inviteCode.get("usergroup_id"): # type: ignore
# Add user to UserGroup # Add user to UserGroup
await add_users_to_usergroup( await add_users_to_usergroup(
request, request,
db_session, db_session,
InternalUser(id=0), 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), str(user.id),
) )

View file

@ -15,9 +15,14 @@ import {
Users, Users,
Calendar, Calendar,
Lightbulb, Lightbulb,
X X,
ExternalLink
} from 'lucide-react' } from 'lucide-react'
import { getUserAvatarMediaDirectory } from '@services/media/media' 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 { interface UserProfileClientProps {
userData: any; userData: any;
@ -67,7 +72,31 @@ const ImageModal: React.FC<{
}; };
function UserProfileClient({ userData, profile }: UserProfileClientProps) { 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 [selectedImage, setSelectedImage] = React.useState<{ url: string; caption?: string } | null>(null);
const [userCourses, setUserCourses] = React.useState<any[]>([]);
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 IconComponent = ({ iconName }: { iconName: string }) => {
const IconElement = ICON_MAP[iconName as keyof typeof ICON_MAP] const IconElement = ICON_MAP[iconName as keyof typeof ICON_MAP]
@ -273,6 +302,31 @@ function UserProfileClient({ userData, profile }: UserProfileClientProps) {
))} ))}
</div> </div>
)} )}
{section.type === 'courses' && (
<div>
{isLoadingCourses ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
) : userCourses.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 auto-rows-fr">
{userCourses.map((course) => (
<div key={course.id} className="flex">
<CourseThumbnailLanding
course={course}
orgslug={userData.org_slug || course.org_slug}
/>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
No courses found
</div>
)}
</div>
)}
</div> </div>
))} ))}
</div> </div>

View file

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd' 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 { Input } from "@components/ui/input"
import { Textarea } from "@components/ui/textarea" import { Textarea } from "@components/ui/textarea"
import { Label } from "@components/ui/label" import { Label } from "@components/ui/label"
@ -48,6 +48,11 @@ const SECTION_TYPES = {
icon: MapPin, icon: MapPin,
label: 'Affiliation', label: 'Affiliation',
description: 'Add organizational affiliations' description: 'Add organizational affiliations'
},
'courses': {
icon: BookOpen,
label: 'Courses',
description: 'Display authored courses'
} }
} as const } as const
@ -94,6 +99,14 @@ interface ProfileAffiliation {
logoUrl: string; logoUrl: string;
} }
interface Course {
id: string;
title: string;
description: string;
thumbnail?: string;
status: string;
}
interface BaseSection { interface BaseSection {
id: string; id: string;
type: keyof typeof SECTION_TYPES; type: keyof typeof SECTION_TYPES;
@ -135,6 +148,11 @@ interface AffiliationSection extends BaseSection {
affiliations: ProfileAffiliation[]; affiliations: ProfileAffiliation[];
} }
interface CoursesSection extends BaseSection {
type: 'courses';
// No need to store courses as they will be fetched from API
}
type ProfileSection = type ProfileSection =
| ImageGallerySection | ImageGallerySection
| TextSection | TextSection
@ -142,7 +160,8 @@ type ProfileSection =
| SkillsSection | SkillsSection
| ExperienceSection | ExperienceSection
| EducationSection | EducationSection
| AffiliationSection; | AffiliationSection
| CoursesSection;
interface ProfileData { interface ProfileData {
sections: ProfileSection[]; sections: ProfileSection[];
@ -196,7 +215,7 @@ const UserProfileBuilder = () => {
const baseSection = { const baseSection = {
id: `section-${Date.now()}`, id: `section-${Date.now()}`,
type, type,
title: `New ${SECTION_TYPES[type].label} Section` title: `${SECTION_TYPES[type].label} Section`
} }
switch (type) { switch (type) {
@ -242,6 +261,11 @@ const UserProfileBuilder = () => {
type: 'affiliation', type: 'affiliation',
affiliations: [] affiliations: []
} }
case 'courses':
return {
...baseSection,
type: 'courses'
}
} }
} }
@ -507,6 +531,8 @@ const SectionEditor: React.FC<SectionEditorProps> = ({ section, onChange }) => {
return <EducationEditor section={section} onChange={onChange} /> return <EducationEditor section={section} onChange={onChange} />
case 'affiliation': case 'affiliation':
return <AffiliationEditor section={section} onChange={onChange} /> return <AffiliationEditor section={section} onChange={onChange} />
case 'courses':
return <CoursesEditor section={section} onChange={onChange} />
default: default:
return <div>Unknown section type</div> return <div>Unknown section type</div>
} }
@ -1285,4 +1311,35 @@ const AffiliationEditor: React.FC<{
) )
} }
const CoursesEditor: React.FC<{
section: CoursesSection;
onChange: (section: CoursesSection) => void;
}> = ({ section, onChange }) => {
return (
<div className="space-y-6 p-6 bg-white rounded-lg nice-shadow">
<div className="flex items-center space-x-2">
<BookOpen className="w-5 h-5 text-gray-500" />
<h3 className="font-medium text-lg">Courses</h3>
</div>
<div className="space-y-4">
{/* Title */}
<div>
<Label htmlFor="title">Section Title</Label>
<Input
id="title"
value={section.title}
onChange={(e) => onChange({ ...section, title: e.target.value })}
placeholder="Enter section title"
/>
</div>
<div className="text-sm text-gray-500 italic">
Your authored courses will be automatically displayed in this section.
</div>
</div>
</div>
)
}
export default UserProfileBuilder export default UserProfileBuilder

View file

@ -25,6 +25,14 @@ export async function getUserByUsername(username: string, access_token?: string)
return res 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( export async function updateUserAvatar(
user_uuid: any, user_uuid: any,
avatar_file: any, avatar_file: any,