feat: add user owned courses page

This commit is contained in:
swve 2024-11-03 00:41:41 +01:00
parent 9f1d8c58d1
commit 0e97580747
7 changed files with 188 additions and 15 deletions

View file

@ -17,6 +17,7 @@ from src.services.payments.payments_courses import (
unlink_course_from_product, unlink_course_from_product,
get_courses_by_product, get_courses_by_product,
) )
from src.services.payments.payments_users import get_owned_courses
from src.services.payments.payments_webhook import handle_stripe_webhook from src.services.payments.payments_webhook import handle_stripe_webhook
from src.services.payments.stripe import create_checkout_session from src.services.payments.stripe import create_checkout_session
from src.services.payments.payments_access import check_course_paid_access from src.services.payments.payments_access import check_course_paid_access
@ -218,3 +219,12 @@ async def api_get_customers(
Get list of customers and their subscriptions for an organization Get list of customers and their subscriptions for an organization
""" """
return await get_customers(request, org_id, current_user, db_session) return await get_customers(request, org_id, current_user, db_session)
@router.get("/{org_id}/courses/owned")
async def api_get_owned_courses(
request: Request,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
return await get_owned_courses(request, current_user, db_session)

View file

@ -1,9 +1,8 @@
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.organizations import Organization from src.db.organizations import Organization
from src.db.users import PublicUser, AnonymousUser, User from src.db.users import PublicUser, AnonymousUser
from src.db.payments.payments_users import PaymentsUser from src.db.payments.payments_users import PaymentsUser
from src.db.payments.payments_products import PaymentsProduct
from src.services.orgs.orgs import rbac_check from src.services.orgs.orgs import rbac_check
from src.services.payments.payments_products import get_payments_product from src.services.payments.payments_products import get_payments_product
from src.services.users.users import read_user_by_id from src.services.users.users import read_user_by_id

View file

@ -1,9 +1,12 @@
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import Any from typing import Any
from src.db.courses.courses import Course, CourseRead
from src.db.payments.payments_courses import PaymentsCourse
from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData
from src.db.payments.payments_products import PaymentsProduct from src.db.payments.payments_products import PaymentsProduct
from src.db.users import InternalUser, PublicUser, AnonymousUser from src.db.resource_authors import ResourceAuthor
from src.db.users import InternalUser, PublicUser, AnonymousUser, User, UserRead
from src.db.organizations import Organization from src.db.organizations import Organization
from src.services.orgs.orgs import rbac_check from src.services.orgs.orgs import rbac_check
from datetime import datetime from datetime import datetime
@ -185,3 +188,59 @@ async def delete_payment_user(
# Delete payment user # Delete payment user
db_session.delete(payment_user) db_session.delete(payment_user)
db_session.commit() db_session.commit()
async def get_owned_courses(
request: Request,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> list[CourseRead]:
# Anonymous users don't own any courses
if isinstance(current_user, AnonymousUser):
return []
# Get all active/completed payment users for the current user
statement = select(PaymentsUser).where(
PaymentsUser.user_id == current_user.id,
PaymentsUser.status.in_([PaymentStatusEnum.ACTIVE, PaymentStatusEnum.COMPLETED]) # type: ignore
)
payment_users = db_session.exec(statement).all()
# Get all product IDs from payment users
product_ids = [pu.payment_product_id for pu in payment_users]
# Get all courses linked to these products
courses = []
for product_id in product_ids:
# Get courses linked to this product through PaymentsCourse
statement = (
select(Course)
.join(PaymentsCourse, Course.id == PaymentsCourse.course_id) # type: ignore
.where(PaymentsCourse.payment_product_id == product_id)
)
product_courses = db_session.exec(statement).all()
courses.extend(product_courses)
# Remove duplicates by converting to set and back to list
unique_courses = list({course.id: course for course in courses}.values())
# Get authors for each course and convert to CourseRead
course_reads = []
for course in unique_courses:
# Get course authors
authors_statement = (
select(User)
.join(ResourceAuthor)
.where(ResourceAuthor.resource_uuid == course.course_uuid)
)
authors = db_session.exec(authors_statement).all()
# Convert authors to UserRead
author_reads = [UserRead.model_validate(author) for author in authors]
# Create CourseRead object
course_read = CourseRead(**course.model_dump(), authors=author_reads)
course_reads.append(course_read)
return course_reads

View file

@ -0,0 +1,59 @@
'use client'
import React from 'react'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import useSWR from 'swr'
import { getOwnedCourses } from '@services/payments/payments'
import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail'
import PageLoading from '@components/Objects/Loaders/PageLoading'
import { BookOpen } from 'lucide-react'
function OwnedCoursesPage() {
const org = useOrg() as any
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const { data: ownedCourses, error, isLoading } = useSWR(
org ? [`/payments/${org.id}/courses/owned`, access_token] : null,
([url, token]) => getOwnedCourses(org.id, token)
)
if (isLoading) return <PageLoading />
if (error) return <div>Error loading owned courses</div>
return (
<div className="h-full w-full bg-[#f8f8f8] pl-10 pr-10 pt-5 ">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-6">
<h1 className="font-bold text-xl text-gray-800">My Courses</h1>
<h2 className="text-gray-500 text-md">Courses you have purchased or subscribed to</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full">
{ownedCourses?.map((course: any) => (
<div key={course.course_uuid} className="p-3">
<CourseThumbnail course={course} orgslug={org.slug} />
</div>
))}
{(!ownedCourses || ownedCourses.length === 0) && (
<div className="col-span-full flex justify-center items-center py-8">
<div className="text-center">
<div className="mb-4">
<BookOpen className="w-12 h-12 mx-auto text-gray-400" />
</div>
<h2 className="text-xl font-bold text-gray-600 mb-2">
No purchased courses
</h2>
<p className="text-md text-gray-400">
You haven't purchased any courses yet
</p>
</div>
</div>
)}
</div>
</div>
)
}
export default OwnedCoursesPage

View file

@ -3,7 +3,7 @@ import { useOrg } from '@components/Contexts/OrgContext'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import ToolTip from '@components/StyledElements/Tooltip/Tooltip' import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
import LearnHouseDashboardLogo from '@public/dashLogo.png' import LearnHouseDashboardLogo from '@public/dashLogo.png'
import { Backpack, BadgeDollarSign, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react' import { Backpack, BadgeDollarSign, BookCopy, Home, LogOut, Package2, School, Settings, Users, Vault } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
@ -147,23 +147,41 @@ function DashLeftMenu() {
<UserAvatar border="border-4" width={35} /> <UserAvatar border="border-4" width={35} />
</div> </div>
</ToolTip> </ToolTip>
<div className="flex items-center flex-col space-y-1"> <div className="flex items-center flex-col space-y-3">
<ToolTip <div className="flex flex-col space-y-1 py-1">
content={session.data.user.username + "'s Settings"} <ToolTip
content={session.data.user.username + "'s Owned Courses"}
slateBlack
sideOffset={8}
side="right"
>
<Link
href={'/dash/user-account/owned'}
className="py-1"
>
<Package2
className="mx-auto text-neutral-400 cursor-pointer"
size={18}
/>
</Link>
</ToolTip>
<ToolTip
content={session.data.user.username + "'s Settings"}
slateBlack slateBlack
sideOffset={8} sideOffset={8}
side="right" side="right"
> >
<Link <Link
href={'/dash/user-account/settings/general'} href={'/dash/user-account/settings/general'}
className="py-3" className="py-1"
> >
<Settings <Settings
className="mx-auto text-neutral-400 cursor-pointer" className="mx-auto text-neutral-400 cursor-pointer"
size={18} size={18}
/> />
</Link> </Link>
</ToolTip> </ToolTip>
</div>
<ToolTip <ToolTip
content={'Logout'} content={'Logout'}
slateBlack slateBlack

View file

@ -2,16 +2,17 @@
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import Link from 'next/link' import Link from 'next/link'
import { Settings } from 'lucide-react' import { Package2, Settings } from 'lucide-react'
import UserAvatar from '@components/Objects/UserAvatar' import UserAvatar from '@components/Objects/UserAvatar'
import useAdminStatus from '@components/Hooks/useAdminStatus' import useAdminStatus from '@components/Hooks/useAdminStatus'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import { getUriWithoutOrg } from '@services/config/config' import { getUriWithoutOrg } from '@services/config/config'
import Tooltip from '@components/StyledElements/Tooltip/Tooltip'
export const HeaderProfileBox = () => { export const HeaderProfileBox = () => {
const session = useLHSession() as any const session = useLHSession() as any
const isUserAdmin = useAdminStatus() const isUserAdmin = useAdminStatus()
const org = useOrg() as any const org = useOrg() as any
useEffect(() => { } useEffect(() => { }
@ -39,12 +40,30 @@ export const HeaderProfileBox = () => {
<p className='text-sm capitalize'>{session.data.user.username}</p> <p className='text-sm capitalize'>{session.data.user.username}</p>
{isUserAdmin.isAdmin && <div className="text-[10px] bg-rose-300 px-2 font-bold rounded-md shadow-inner py-1">ADMIN</div>} {isUserAdmin.isAdmin && <div className="text-[10px] bg-rose-300 px-2 font-bold rounded-md shadow-inner py-1">ADMIN</div>}
</div> </div>
<div className="flex items-center space-x-2">
<Tooltip
content={"Your Owned Courses"}
sideOffset={15}
side="bottom"
>
<Link className="text-gray-600" href={'/dash/user-account/owned'}>
<Package2 size={14} />
</Link>
</Tooltip>
<Tooltip
content={"Your Settings"}
sideOffset={15}
side="bottom"
>
<Link className="text-gray-600" href={'/dash'}>
<Settings size={14} />
</Link>
</Tooltip>
</div>
<div className="py-4"> <div className="py-4">
<UserAvatar border="border-4" rounded="rounded-lg" width={30} /> <UserAvatar border="border-4" rounded="rounded-lg" width={30} />
</div> </div>
<Link className="text-gray-600" href={'/dash'}>
<Settings size={14} />
</Link>
</div> </div>
</AccountArea> </AccountArea>
)} )}

View file

@ -53,4 +53,13 @@ export async function getOrgCustomers(orgId: number, access_token: string) {
); );
const res = await errorHandling(result); const res = await errorHandling(result);
return res; return res;
}
export async function getOwnedCourses(orgId: number, access_token: string) {
const result = await fetch(
`${getAPIUrl()}payments/${orgId}/courses/owned`,
RequestBodyWithAuthHeader('GET', null, null, access_token)
);
const res = await errorHandling(result);
return res;
} }