mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: Add User Courses in Profile
This commit is contained in:
parent
31c27bb70e
commit
b1aec48947
6 changed files with 233 additions and 8 deletions
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 ##
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue