feat: init usergroup linking to a course

This commit is contained in:
swve 2024-03-30 10:22:38 +00:00
parent a6152ef1f5
commit 4f75e6a90a
12 changed files with 428 additions and 55 deletions

View file

@ -9,6 +9,7 @@ from src.services.users.usergroups import (
add_users_to_usergroup, add_users_to_usergroup,
create_usergroup, create_usergroup,
delete_usergroup_by_id, delete_usergroup_by_id,
get_usergroups_by_resource,
read_usergroup_by_id, read_usergroup_by_id,
read_usergroups_by_org_id, read_usergroups_by_org_id,
remove_resources_from_usergroup, remove_resources_from_usergroup,
@ -64,6 +65,19 @@ async def api_get_usergroups(
""" """
return await read_usergroups_by_org_id(request, db_session, current_user, org_id) return await read_usergroups_by_org_id(request, db_session, current_user, org_id)
@router.get("/resource/{resource_uuid}", response_model=list[UserGroupRead], tags=["usergroups"])
async def api_get_usergroupsby_resource(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
resource_uuid: str,
) -> list[UserGroupRead]:
"""
Get UserGroups by Org
"""
return await get_usergroups_by_resource(request, db_session, current_user, resource_uuid)
@router.put("/{usergroup_id}", response_model=UserGroupRead, tags=["usergroups"]) @router.put("/{usergroup_id}", response_model=UserGroupRead, tags=["usergroups"])
async def api_update_usergroup( async def api_update_usergroup(

View file

@ -119,6 +119,41 @@ async def read_usergroups_by_org_id(
return usergroups return usergroups
async def get_usergroups_by_resource(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
resource_uuid: str,
) -> list[UserGroupRead]:
statement = select(UserGroupResource).where(
UserGroupResource.resource_uuid == resource_uuid
)
usergroup_resources = db_session.exec(statement).all()
# RBAC check
await rbac_check(
request,
usergroup_uuid="usergroup_X",
current_user=current_user,
action="read",
db_session=db_session,
)
usergroup_ids = [usergroup.usergroup_id for usergroup in usergroup_resources]
# get usergroups
usergroups = []
for usergroup_id in usergroup_ids:
statement = select(UserGroup).where(UserGroup.id == usergroup_id)
usergroup = db_session.exec(statement).first()
usergroups.append(usergroup)
usergroups = [UserGroupRead.from_orm(usergroup) for usergroup in usergroups]
return usergroups
async def update_usergroup_by_id( async def update_usergroup_by_id(
request: Request, request: Request,
db_session: Session, db_session: Session,
@ -258,7 +293,6 @@ async def remove_users_from_usergroup(
detail="UserGroup not found", detail="UserGroup not found",
) )
# RBAC check
# RBAC check # RBAC check
await rbac_check( await rbac_check(
request, request,
@ -312,8 +346,21 @@ async def add_resources_to_usergroup(
resources_uuids_array = resources_uuids.split(",") resources_uuids_array = resources_uuids.split(",")
for resource_uuid in resources_uuids_array: for resource_uuid in resources_uuids_array:
# TODO : Find a way to check if resource exists # Check if a link between UserGroup and Resource already exists
statement = select(UserGroupResource).where(
UserGroupResource.usergroup_id == usergroup_id,
UserGroupResource.resource_uuid == resource_uuid,
)
usergroup_resource = db_session.exec(statement).first()
if usergroup_resource:
raise HTTPException(
status_code=400,
detail=f"Resource {resource_uuid} already exists in UserGroup",
)
continue
# TODO : Find a way to check if resource really exists
usergroup_obj = UserGroupResource( usergroup_obj = UserGroupResource(
usergroup_id=usergroup_id, usergroup_id=usergroup_id,
resource_uuid=resource_uuid, resource_uuid=resource_uuid,

View file

@ -7,7 +7,8 @@ import Link from 'next/link'
import { CourseOverviewTop } from '@components/Dashboard/UI/CourseOverviewTop' import { CourseOverviewTop } from '@components/Dashboard/UI/CourseOverviewTop'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import EditCourseGeneral from '@components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral' import EditCourseGeneral from '@components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral'
import { GalleryVerticalEnd, Info } from 'lucide-react' import { GalleryVerticalEnd, Info, Lock, UserRoundCog } from 'lucide-react'
import EditCourseAccess from '@components/Dashboard/Course/EditCourseAccess/EditCourseAccess'
export type CourseOverviewParams = { export type CourseOverviewParams = {
orgslug: string orgslug: string
@ -24,9 +25,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
return ( return (
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto,1fr]"> <div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto,1fr]">
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)}> <CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)}>
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]"> <div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]">
<CourseOverviewTop params={params} /> <CourseOverviewTop params={params} />
<div className="flex space-x-5 font-black text-sm"> <div className="flex space-x-3 font-black text-xs">
<Link <Link
href={ href={
getUriWithOrg(params.orgslug, '') + getUriWithOrg(params.orgslug, '') +
@ -34,8 +35,7 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
} }
> >
<div <div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${ className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'general'
params.subpage.toString() === 'general'
? 'border-b-4' ? 'border-b-4'
: 'opacity-50' : 'opacity-50'
} cursor-pointer`} } cursor-pointer`}
@ -46,6 +46,24 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
</div> </div>
</div> </div>
</Link> </Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/access`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'access'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<UserRoundCog size={16} />
<div>Access</div>
</div>
</div>
</Link>
<Link <Link
href={ href={
getUriWithOrg(params.orgslug, '') + getUriWithOrg(params.orgslug, '') +
@ -53,8 +71,7 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
} }
> >
<div <div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${ className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'content'
params.subpage.toString() === 'content'
? 'border-b-4' ? 'border-b-4'
: 'opacity-50' : 'opacity-50'
} cursor-pointer`} } cursor-pointer`}
@ -65,7 +82,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
</div> </div>
</div> </div>
</Link> </Link>
</div> </div>
</div> </div>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -74,16 +93,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="h-full overflow-y-auto" className="h-full overflow-y-auto"
> >
{params.subpage == 'content' ? ( {params.subpage == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')}
<EditCourseStructure orgslug={params.orgslug} /> {params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
) : ( {params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
''
)}
{params.subpage == 'general' ? (
<EditCourseGeneral orgslug={params.orgslug} />
) : (
''
)}
</motion.div> </motion.div>
</CourseProvider> </CourseProvider>
</div> </div>

View file

@ -1,6 +1,7 @@
'use client' 'use client'
import { OrgProvider } from '@components/Contexts/OrgContext' import { OrgProvider } from '@components/Contexts/OrgContext'
import SessionProvider from '@components/Contexts/SessionContext' import SessionProvider from '@components/Contexts/SessionContext'
import Toast from '@components/StyledElements/Toast/Toast'
import '@styles/globals.css' import '@styles/globals.css'
export default function RootLayout({ export default function RootLayout({
@ -12,6 +13,7 @@ export default function RootLayout({
}) { }) {
return ( return (
<div> <div>
<Toast />
<OrgProvider orgslug={params.orgslug}> <OrgProvider orgslug={params.orgslug}>
<SessionProvider>{children}</SessionProvider> <SessionProvider>{children}</SessionProvider>
</OrgProvider> </OrgProvider>

View file

@ -0,0 +1,211 @@
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'
import LinkToUserGroup from '@components/Objects/Modals/Dash/EditCourseAccess/LinkToUserGroup'
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
import Modal from '@components/StyledElements/Modal/Modal'
import { getAPIUrl } from '@services/config/config'
import { unLinkResourcesToUserGroup } from '@services/usergroups/usergroups'
import { swrFetcher } from '@services/utils/ts/requests'
import { Globe, Users, UsersRound, X } from 'lucide-react'
import React from 'react'
import toast from 'react-hot-toast'
import useSWR, { mutate } from 'swr'
type EditCourseAccessProps = {
orgslug: string
course_uuid?: string
}
function EditCourseAccess(props: EditCourseAccessProps) {
const [error, setError] = React.useState('')
const course = useCourse() as any
const dispatchCourse = useCourseDispatch() as any
const courseStructure = course.courseStructure
const { data: usergroups } = useSWR(
courseStructure ? `${getAPIUrl()}usergroups/resource/${courseStructure.course_uuid}` : null,
swrFetcher
)
const [isPublic, setIsPublic] = React.useState(courseStructure.public)
React.useEffect(() => {
// This code will run whenever form values are updated
if (isPublic !== courseStructure.public) {
dispatchCourse({ type: 'setIsNotSaved' })
const updatedCourse = {
...courseStructure,
public: isPublic,
}
dispatchCourse({ type: 'setCourseStructure', payload: updatedCourse })
}
}, [course, isPublic])
return (
<div>
{' '}
<div className="h-6"></div>
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3 ">
<h1 className="font-bold text-xl text-gray-800">Access to the course</h1>
<h2 className="text-gray-500 text-sm">
{' '}
Choose if want your course to be publicly available on the internet or only accessible to signed in users{' '}
</h2>
</div>
<div className="flex space-x-2 mx-auto mb-3">
<ConfirmationModal
confirmationButtonText="Change to Public"
confirmationMessage="Are you sure you want this course to be publicly available on the internet ?"
dialogTitle={'Change to Public ?'}
dialogTrigger={
<div className="w-full h-[200px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 ease-linear transition-all">
{isPublic ? (
<div className="bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg">
Active
</div>
) : null}
<div className="flex flex-col space-y-1 justify-center items-center h-full">
<Globe className="text-slate-400" size={40}></Globe>
<div className="text-2xl text-slate-700 font-bold">
Public
</div>
<div className="text-gray-400 text-md tracking-tight w-[500px] leading-5 text-center">
The Course is publicly available on the internet, it is indexed by search engines and can be accessed by anyone
</div>
</div>
</div>
}
functionToExecute={() => {
setIsPublic(true)
}}
status="info"
></ConfirmationModal>
<ConfirmationModal
confirmationButtonText="Change to Users Only"
confirmationMessage="Are you sure you want this course to be only accessible to signed in users ?"
dialogTitle={'Change to Users Only ?'}
dialogTrigger={
<div className="w-full h-[200px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 ease-linear transition-all">
{!isPublic ? (
<div className="bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg">
Active
</div>
) : null}
<div className="flex flex-col space-y-1 justify-center items-center h-full">
<Users className="text-slate-400" size={40}></Users>
<div className="text-2xl text-slate-700 font-bold">
Users Only
</div>
<div className="text-gray-400 text-md tracking-tight w-[500px] leading-5 text-center">
The Course is only accessible to signed in users, additionaly you can choose which UserGroups can access this course
</div>
</div>
</div>
}
functionToExecute={() => {
setIsPublic(false)
}}
status="info"
></ConfirmationModal>
</div>
<UserGroupsSection usergroups={usergroups} />
</div>
</div>
)
}
function UserGroupsSection({ usergroups }: { usergroups: any[] }) {
const course = useCourse() as any
const [userGroupModal, setUserGroupModal] = React.useState(false)
const removeUserGroupLink = async (usergroup_id: number) => {
const res = await unLinkResourcesToUserGroup(usergroup_id, course.courseStructure.course_uuid)
if (res.status === 200) {
toast.success('Successfully unliked from usergroup')
mutate(`${getAPIUrl()}usergroups/resource/${course.courseStructure.course_uuid}`)
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
return (
<>
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3 ">
<h1 className="font-bold text-xl text-gray-800">UserGroups</h1>
<h2 className="text-gray-500 text-sm">
{' '}
Choose which UserGroups can access this course{' '}
</h2>
</div>
<table className="table-auto w-full text-left whitespace-nowrap rounded-md overflow-hidden">
<thead className="bg-gray-100 text-gray-500 rounded-xl uppercase">
<tr className="font-bolder text-sm">
<th className="py-3 px-4">Name</th>
<th className="py-3 px-4">Actions</th>
</tr>
</thead>
<>
<tbody className="mt-5 bg-white rounded-md">
{usergroups?.map((usergroup: any) => (
<tr
key={usergroup.invite_code_uuid}
className="border-b border-gray-100 text-sm"
>
<td className="py-3 px-4">{usergroup.name}</td>
<td className="py-3 px-4">
<ConfirmationModal
confirmationButtonText="Delete Link"
confirmationMessage="Users from this UserGroup will no longer have access to this course"
dialogTitle={'Unlink UserGroup ?'}
dialogTrigger={
<button className="mr-2 flex space-x-2 hover:cursor-pointer p-1 px-3 bg-rose-700 rounded-md font-bold items-center text-sm text-rose-100">
<X className="w-4 h-4" />
<span> Delete link</span>
</button>
}
functionToExecute={() => {
removeUserGroupLink(usergroup.id)
}}
status="warning"
></ConfirmationModal>
</td>
</tr>
))}
</tbody>
</>
</table>
<div className='flex flex-row-reverse mt-3 mr-2'>
<Modal
isDialogOpen={
userGroupModal
}
onOpenChange={() =>
setUserGroupModal(!userGroupModal)
}
minHeight="no-min"
dialogContent={
<LinkToUserGroup setUserGroupModal={setUserGroupModal} />
}
dialogTitle="Link Course to a UserGroup"
dialogDescription={
'Choose which UserGroups can access this course'
}
dialogTrigger={
<button
className=" flex space-x-2 hover:cursor-pointer p-1 px-3 bg-green-700 rounded-md font-bold items-center text-sm text-green-100"
>
<UsersRound className="w-4 h-4" />
<span>Link to a UserGroup</span>
</button>
}
/>
</div></>
)
}
export default EditCourseAccess

View file

@ -116,10 +116,11 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
message={formik.errors.description} message={formik.errors.description}
/> />
<Form.Control asChild> <Form.Control asChild>
<Textarea <Input
style={{ backgroundColor: 'white' }} style={{ backgroundColor: 'white' }}
onChange={formik.handleChange} onChange={formik.handleChange}
value={formik.values.description} value={formik.values.description}
type='text'
required required
/> />
</Form.Control> </Form.Control>
@ -177,26 +178,7 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
</Form.Control> </Form.Control>
</FormField> </FormField>
<FormField className="flex items-center h-10" name="public">
<div className="flex my-auto items-center">
<label
className="text-black text-[15px] leading-none pr-[15px]"
htmlFor="public-course"
>
Public Course
</label>
<Switch.Root
className="w-[42px] h-[25px] bg-neutral-200 rounded-full relative data-[state=checked]:bg-neutral-500 outline-none cursor-default"
id="public-course"
onCheckedChange={(checked) =>
formik.setFieldValue('public', checked)
}
checked={formik.values.public === 'true'}
>
<Switch.Thumb className="block w-[21px] h-[21px] bg-white rounded-full shadow-[0_2px_2px] shadow-neutral-300 transition-transform duration-100 translate-x-0.5 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</FormField>
</FormLayout> </FormLayout>
</div> </div>
)} )}

View file

@ -46,18 +46,17 @@ function ThumbnailUpdate() {
{localThumbnail ? ( {localThumbnail ? (
<img <img
src={URL.createObjectURL(localThumbnail)} src={URL.createObjectURL(localThumbnail)}
className={`${ className={`${isLoading ? 'animate-pulse' : ''
isLoading ? 'animate-pulse' : ''
} shadow w-[200px] h-[100px] rounded-md`} } shadow w-[200px] h-[100px] rounded-md`}
/> />
) : ( ) : (
<img <img
src={`${getCourseThumbnailMediaDirectory( src={`${course.courseStructure.thumbnail_image ? getCourseThumbnailMediaDirectory(
org?.org_uuid, org?.org_uuid,
course.courseStructure.course_uuid, course.courseStructure.course_uuid,
course.courseStructure.thumbnail_image course.courseStructure.thumbnail_image
)}`} ) : '/empty_thumbnail.png'}`}
className="shadow w-[200px] h-[100px] rounded-md" className="shadow w-[200px] h-[100px] rounded-md bg-gray-200"
/> />
)} )}
</div> </div>

View file

@ -64,6 +64,7 @@ function BreadCrumbs(props: BreadCrumbsProps) {
</div> </div>
</div> </div>
</div> </div>
<div className="h-2"></div>
</div> </div>
) )
} }

View file

@ -27,7 +27,7 @@ export function CourseOverviewTop({
last_breadcrumb={course.courseStructure.name} last_breadcrumb={course.courseStructure.name}
></BreadCrumbs> ></BreadCrumbs>
<div className="flex"> <div className="flex">
<div className="flex py-5 grow items-center"> <div className="flex py-3 grow items-center">
<Link <Link
href={getUriWithOrg(org?.slug, '') + `/course/${params.courseuuid}`} href={getUriWithOrg(org?.slug, '') + `/course/${params.courseuuid}`}
> >

View file

@ -0,0 +1,71 @@
'use client';
import { useCourse } from '@components/Contexts/CourseContext';
import { useOrg } from '@components/Contexts/OrgContext';
import { getAPIUrl } from '@services/config/config';
import { linkResourcesToUserGroup } from '@services/usergroups/usergroups';
import { swrFetcher } from '@services/utils/ts/requests';
import React, { useEffect } from 'react'
import toast from 'react-hot-toast';
import useSWR, { mutate } from 'swr'
type LinkToUserGroupProps = {
// React function, todo: fix types
setUserGroupModal: any
}
function LinkToUserGroup(props: LinkToUserGroupProps) {
const course = useCourse() as any
const org = useOrg() as any
const courseStructure = course.courseStructure
const { data: usergroups } = useSWR(
courseStructure && org ? `${getAPIUrl()}usergroups/org/${org.id}` : null,
swrFetcher
)
const [selectedUserGroup, setSelectedUserGroup] = React.useState(null) as any
const handleLink = async () => {
console.log('selectedUserGroup', selectedUserGroup)
const res = await linkResourcesToUserGroup(selectedUserGroup, courseStructure.course_uuid)
if (res.status === 200) {
props.setUserGroupModal(false)
toast.success('Successfully linked to usergroup')
mutate(`${getAPIUrl()}usergroups/resource/${courseStructure.course_uuid}`)
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
useEffect(() => {
if (usergroups && usergroups.length > 0) {
setSelectedUserGroup(usergroups[0].id)
}
}
, [usergroups])
return (
<div className='p-4 flex-row flex justify-between items-center'>
<div className='py-3'>
<span className='px-3 text-gray-400 font-bold rounded-full py-1 bg-gray-100 mx-3'>UserGroup Name </span>
<select
onChange={(e) => setSelectedUserGroup(e.target.value)}
defaultValue={selectedUserGroup}
>
{usergroups && usergroups.map((group: any) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</div>
<div className='py-3'>
<button onClick={() => { handleLink() }} className='bg-green-700 text-white font-bold px-4 py-2 rounded-md shadow'>Link</button>
</div>
</div>
)
}
export default LinkToUserGroup

View file

@ -15,7 +15,7 @@ export const FormLabelAndMessage = (props: {
message?: string message?: string
}) => ( }) => (
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<FormLabel className="grow">{props.label}</FormLabel> <FormLabel className="grow text-sm">{props.label}</FormLabel>
{(props.message && ( {(props.message && (
<div className="text-red-700 text-sm items-center rounded-md flex space-x-1"> <div className="text-red-700 text-sm items-center rounded-md flex space-x-1">
<Info size={10} /> <Info size={10} />
@ -35,7 +35,6 @@ export const FormField = styled(Form.Field, {
}) })
export const FormLabel = styled(Form.Label, { export const FormLabel = styled(Form.Label, {
fontSize: 15,
fontWeight: 500, fontWeight: 500,
lineHeight: '35px', lineHeight: '35px',
color: 'black', color: 'black',

View file

@ -0,0 +1,35 @@
import { getAPIUrl } from '@services/config/config'
import { RequestBody, getResponseMetadata } from '@services/utils/ts/requests'
export async function getUserGroups(org_id: any) {
const result: any = await fetch(
`${getAPIUrl()}usergroups/org/${org_id}`,
RequestBody('GET', null, null)
)
const res = await getResponseMetadata(result)
return res
}
export async function linkResourcesToUserGroup(
usergroup_id: any,
resource_uuids: any
) {
const result: any = await fetch(
`${getAPIUrl()}usergroups/${usergroup_id}/add_resources?resource_uuids=${resource_uuids}`,
RequestBody('POST', null, null)
)
const res = await getResponseMetadata(result)
return res
}
export async function unLinkResourcesToUserGroup(
usergroup_id: any,
resource_uuids: any
) {
const result: any = await fetch(
`${getAPIUrl()}usergroups/${usergroup_id}/remove_resources?resource_uuids=${resource_uuids}`,
RequestBody('DELETE', null, null)
)
const res = await getResponseMetadata(result)
return res
}