Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
55beb10
Module removal and editing improvements
anurag2787 Dec 27, 2025
e867b1e
fixed coderabbit review
anurag2787 Dec 27, 2025
18b1b20
feat: implement module deletion and editing with proper permissions
anurag2787 Dec 27, 2025
8aec659
fixed check error
anurag2787 Dec 27, 2025
c525845
fixed coderabbit review
anurag2787 Dec 27, 2025
afcdc8d
Merge branch 'main' of github.com:anurag2787/Nest into module-removal…
anurag2787 Dec 29, 2025
454d961
added functionaility to edit module by mentor
anurag2787 Dec 29, 2025
b0b4220
fixed sonar and coderabbit review
anurag2787 Dec 29, 2025
e7540f2
Added no sonar
anurag2787 Dec 29, 2025
44b51e9
fixed nosoanr
anurag2787 Dec 31, 2025
f729e3d
updated nosonar comment
anurag2787 Dec 31, 2025
3b7162d
update nosonar warning
anurag2787 Dec 31, 2025
968b9da
fixed coderabbit review
anurag2787 Dec 31, 2025
8afb6ed
Merge branch 'main' of github.com:anurag2787/Nest into module-removal…
anurag2787 Dec 31, 2025
f11d484
Merge branch 'main' of github.com:anurag2787/Nest into module-removal…
anurag2787 Jan 9, 2026
3d13803
Fixed coderabbit review
anurag2787 Jan 9, 2026
9292706
Resolve coderabbit review
anurag2787 Jan 9, 2026
dca555f
fix code
anurag2787 Jan 9, 2026
1233914
fixed coderabbit comment
anurag2787 Jan 9, 2026
4b79b24
fixed
anurag2787 Jan 9, 2026
0a330f6
fixed check command fail
anurag2787 Jan 9, 2026
9a0284a
Merge branch 'main' of github.com:anurag2787/Nest into module-removal…
anurag2787 Jan 15, 2026
d2afc06
fixed sonarqube warning
anurag2787 Jan 15, 2026
04e794c
Fixed sonarqube warning
anurag2787 Jan 15, 2026
851203b
Remove view Issues from mentor
anurag2787 Jan 16, 2026
a79a8e5
Merge branch 'main' into module-removal-and-editing
anurag2787 Jan 16, 2026
9be6301
Merge branch 'main' into module-removal-and-editing
anurag2787 Jan 24, 2026
3cf76b3
fixed merge conflict
anurag2787 Jan 24, 2026
0e63158
fixed sonarqube issue
anurag2787 Jan 24, 2026
3da7d25
fixed code rabbit review
anurag2787 Jan 24, 2026
b12ad1e
fixed check
anurag2787 Jan 24, 2026
0992bab
fixed coderabbit review
anurag2787 Jan 24, 2026
4da0ada
Merge branch 'main' into module-removal-and-editing
anurag2787 Jan 24, 2026
96d96a5
Merge branch 'main' into module-removal-and-editing
anurag2787 Jan 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 149 additions & 17 deletions backend/apps/mentorship/api/internal/mutations/module.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""GraphQL mutations for mentorship modules in the mentorship app."""

import contextlib
import logging
from datetime import datetime

Expand Down Expand Up @@ -83,16 +84,24 @@ def create_module(self, info: strawberry.Info, input_data: CreateModuleInput) ->
try:
program = Program.objects.get(key=input_data.program_key)
project = Project.objects.get(id=input_data.project_id)
creator_as_mentor = Mentor.objects.get(nest_user=user)
except (Program.DoesNotExist, Project.DoesNotExist) as e:
msg = f"{e.__class__.__name__} matching query does not exist."
raise ObjectDoesNotExist(msg) from e
except Mentor.DoesNotExist as e:

creator_as_mentor = None
with contextlib.suppress(Mentor.DoesNotExist):
creator_as_mentor = Mentor.objects.get(nest_user=user)

if creator_as_mentor is None and hasattr(user, "github_user"):
with contextlib.suppress(Mentor.DoesNotExist):
creator_as_mentor = Mentor.objects.get(github_user=user.github_user)

if creator_as_mentor is None:
msg = "Only mentors can create modules."
raise PermissionDenied(msg) from e
raise PermissionDenied(msg)

if not program.admins.filter(id=creator_as_mentor.id).exists():
raise PermissionDenied
raise PermissionDenied("Only program admins can create modules.")

started_at, ended_at = _validate_module_dates(
input_data.started_at,
Expand Down Expand Up @@ -329,7 +338,15 @@ def clear_task_deadline(
@strawberry.mutation(permission_classes=[IsAuthenticated])
@transaction.atomic
def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> ModuleNode:
"""Update an existing mentorship module. User must be an admin of the program."""
"""Update an existing mentorship module.

User must either be:
- An admin of the program, or
- A mentor explicitly assigned to this module

Admins can edit any field and manage mentor assignments.
Module mentors can edit module details but cannot modify mentor assignments.
"""
user = info.context.request.user

try:
Expand All @@ -340,19 +357,36 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) ->
except Module.DoesNotExist as e:
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG) from e

try:
creator_as_mentor = Mentor.objects.get(nest_user=user)
except Mentor.DoesNotExist as err:
msg = "Only mentors can edit modules."
# Check if user is a program admin or module mentor
is_program_admin = False
is_module_mentor = False

# Try to find the Mentor object for this user
editor_as_mentor = None
with contextlib.suppress(Mentor.DoesNotExist):
editor_as_mentor = Mentor.objects.get(nest_user=user)

if editor_as_mentor is None and hasattr(user, "github_user"):
with contextlib.suppress(Mentor.DoesNotExist):
editor_as_mentor = Mentor.objects.get(github_user=user.github_user)

# Check permissions if we found a Mentor object
if editor_as_mentor is not None:
is_program_admin = module.program.admins.filter(id=editor_as_mentor.id).exists()
is_module_mentor = module.mentors.filter(id=editor_as_mentor.id).exists()

if not (is_program_admin or is_module_mentor):
msg = (
"You do not have permission to edit this module. "
"Only program admins and module mentors can edit modules."
)
logger.warning(
"User '%s' is not a mentor and cannot edit modules.",
"Unauthorized edit attempt: User '%s' is neither a program admin "
"nor a module mentor for module '%s'.",
user.username,
exc_info=True,
module.name,
)
raise PermissionDenied(msg) from err

if not module.program.admins.filter(id=creator_as_mentor.id).exists():
raise PermissionDenied
raise PermissionDenied(msg)

started_at, ended_at = _validate_module_dates(
input_data.started_at,
Expand Down Expand Up @@ -385,8 +419,28 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) ->
raise ObjectDoesNotExist(msg) from err

if input_data.mentor_logins is not None:
mentors_to_set = resolve_mentors_from_logins(input_data.mentor_logins)
module.mentors.set(mentors_to_set)
if not is_program_admin:
current_logins = {
login.lower()
for login in module.mentors.values_list("github_user__login", flat=True)
}
requested_logins = {login.lower() for login in input_data.mentor_logins}

if requested_logins != current_logins:
msg = "Only program admins can modify mentor assignments."
logger.warning(
"Unauthorized mentor assignment attempt: Non-admin mentor '%s' "
"tried to modify mentors for module '%s'.",
user.username,
module.name,
)
raise PermissionDenied(msg)
# Mentor list unchanged; skip the update
input_data.mentor_logins = None

if input_data.mentor_logins is not None:
mentors_to_set = resolve_mentors_from_logins(input_data.mentor_logins)
module.mentors.set(mentors_to_set)

module.save()

Expand All @@ -407,6 +461,12 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) ->

module.program.save(update_fields=["experience_levels"])

logger.info(
"User '%s' successfully updated module '%s' in program '%s'.",
user.username,
module.name,
module.program.key,
)
program_key = module.program.key

def _invalidate():
Expand All @@ -417,3 +477,75 @@ def _invalidate():
transaction.on_commit(_invalidate)

return module

@strawberry.mutation(permission_classes=[IsAuthenticated])
@transaction.atomic
def delete_module(
self,
info: strawberry.Info,
program_key: str,
module_key: str,
) -> str:
"""Delete a mentorship module. User must be an admin of the program."""
user = info.context.request.user

try:
module = Module.objects.select_related("program").get(
key=module_key, program__key=program_key
)
except Module.DoesNotExist as e:
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG) from e

admin_as_mentor = None
with contextlib.suppress(Mentor.DoesNotExist):
admin_as_mentor = Mentor.objects.get(nest_user=user)

if admin_as_mentor is None and hasattr(user, "github_user"):
with contextlib.suppress(Mentor.DoesNotExist):
admin_as_mentor = Mentor.objects.get(github_user=user.github_user)

if admin_as_mentor is None:
msg = "Only mentors can delete modules."
logger.warning(
"User '%s' is not a mentor and cannot delete modules.",
user.username,
)
raise PermissionDenied(msg)

if not module.program.admins.filter(id=admin_as_mentor.id).exists():
msg = "Only program admins can delete modules."
raise PermissionDenied(msg)

program = module.program
module_name = module.name

# Clean up experience levels if this module is the only one using it
experience_level_to_remove = module.experience_level
if (
experience_level_to_remove in program.experience_levels
and not Module.objects.filter(
program=program, experience_level=experience_level_to_remove
)
.exclude(id=module.id)
.exists()
):
program.experience_levels.remove(experience_level_to_remove)
program.save(update_fields=["experience_levels"])

# Delete the module
module.delete()

def _invalidate():
invalidate_module_cache(module_key, program_key)
invalidate_program_cache(program_key)

transaction.on_commit(_invalidate)

logger.info(
"User '%s' deleted module '%s' from program '%s'.",
user.username,
module_name,
program_key,
)

return f"Module '{module_name}' has been deleted successfully."
20 changes: 17 additions & 3 deletions frontend/__tests__/unit/components/EntityActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,14 @@ describe('EntityActions', () => {

describe('Module Actions - View Issues', () => {
it('navigates to view issues page when View Issues is clicked with moduleKey', () => {
render(<EntityActions type="module" programKey="test-program" moduleKey="test-module" />)
render(
<EntityActions
type="module"
programKey="test-program"
moduleKey="test-module"
isAdmin={true}
/>
)
const button = screen.getByRole('button', { name: /Module actions menu/ })
fireEvent.click(button)

Expand All @@ -95,7 +102,7 @@ describe('EntityActions', () => {
})

it('does not navigate when moduleKey is missing for view issues action', () => {
render(<EntityActions type="module" programKey="test-program" />)
render(<EntityActions type="module" programKey="test-program" isAdmin={true} />)
const button = screen.getByRole('button', { name: /Module actions menu/ })
fireEvent.click(button)

Expand All @@ -106,7 +113,14 @@ describe('EntityActions', () => {
})

it('closes dropdown after clicking View Issues', () => {
render(<EntityActions type="module" programKey="test-program" moduleKey="test-module" />)
render(
<EntityActions
type="module"
programKey="test-program"
moduleKey="test-module"
isAdmin={true}
/>
)
const button = screen.getByRole('button', { name: /Module actions menu/ })
fireEvent.click(button)
expect(button).toHaveAttribute('aria-expanded', 'true')
Expand Down
2 changes: 1 addition & 1 deletion frontend/__tests__/unit/pages/CreateModule.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,5 +121,5 @@ describe('CreateModulePage', () => {
expect(mockCreateModule).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program')
})
})
}, 10000)
})
2 changes: 1 addition & 1 deletion frontend/__tests__/unit/pages/EditModule.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ describe('EditModulePage', () => {
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith({
title: 'Access Denied',
description: 'Only program admins can edit modules.',
description: 'Only program admins and module mentors can edit this module.',
color: 'danger',
variant: 'solid',
timeout: 4000,
Expand Down
13 changes: 13 additions & 0 deletions frontend/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,17 @@ jest.mock('ics', () => {
}
})

jest.mock('@apollo/client/react', () => {
const actual = jest.requireActual('@apollo/client/react')
const mockUseMutation = jest.fn(() => [
jest.fn().mockResolvedValue({ data: {} }),
{ data: null, loading: false, error: null, called: false },
])

return {
...actual,
useMutation: mockUseMutation,
}
})

expect.extend(toHaveNoViolations)
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { addToast } from '@heroui/toast'
import { useParams, useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import React, { useEffect, useState } from 'react'
import { ErrorDisplay, handleAppError } from 'app/global-error'
import { ExperienceLevelEnum } from 'types/__generated__/graphql'
import { ErrorDisplay } from 'app/global-error'
import { ExperienceLevelEnum, UpdateModuleInput } from 'types/__generated__/graphql'
import { UpdateModuleDocument } from 'types/__generated__/moduleMutations.generated'
import { GetProgramAdminsAndModulesDocument } from 'types/__generated__/moduleQueries.generated'
import { GetProgramAndModulesDocument } from 'types/__generated__/programsQueries.generated'
Expand All @@ -20,7 +20,10 @@ import ModuleForm from 'components/ModuleForm'
const EditModulePage = () => {
const { programKey, moduleKey } = useParams<{ programKey: string; moduleKey: string }>()
const router = useRouter()
const { data: sessionData, status: sessionStatus } = useSession()
const { data: sessionData, status: sessionStatus } = useSession() as {
data: ExtendedSession | null
status: string
}

const [formData, setFormData] = useState<ModuleFormData | null>(null)
const [accessStatus, setAccessStatus] = useState<'checking' | 'allowed' | 'denied'>('checking')
Expand Down Expand Up @@ -52,18 +55,22 @@ const EditModulePage = () => {
return
}

const currentUserLogin = (sessionData as ExtendedSession)?.user?.login
const currentUserLogin = sessionData?.user?.login
const isAdmin = data.getProgram.admins?.some(
(admin: { login: string }) => admin.login === currentUserLogin
)

if (isAdmin) {
const isMentor = data.getModule.mentors?.some(
(mentor: { login: string }) => mentor.login === currentUserLogin
)

if (isAdmin || isMentor) {
setAccessStatus('allowed')
} else {
setAccessStatus('denied')
addToast({
title: 'Access Denied',
description: 'Only program admins can edit modules.',
description: 'Only program admins and module mentors can edit this module.',
color: 'danger',
variant: 'solid',
timeout: 4000,
Expand Down Expand Up @@ -96,14 +103,18 @@ const EditModulePage = () => {
if (!formData) return

try {
const input = {
const currentUserLogin = sessionData?.user?.login
const isAdmin = data?.getProgram?.admins?.some(
(admin: { login: string }) => admin.login === currentUserLogin
)

const input: UpdateModuleInput = {
description: formData.description,
domains: parseCommaSeparated(formData.domains),
endedAt: formData.endedAt || null,
experienceLevel: formData.experienceLevel as ExperienceLevelEnum,
key: moduleKey,
labels: parseCommaSeparated(formData.labels),
mentorLogins: parseCommaSeparated(formData.mentorLogins),
name: formData.name,
programKey: programKey,
projectId: formData.projectId,
Expand All @@ -112,6 +123,10 @@ const EditModulePage = () => {
tags: parseCommaSeparated(formData.tags),
}

if (isAdmin) {
input.mentorLogins = parseCommaSeparated(formData.mentorLogins)
}

const result = await updateModule({
awaitRefetchQueries: true,
refetchQueries: [{ query: GetProgramAndModulesDocument, variables: { programKey } }],
Expand All @@ -128,7 +143,22 @@ const EditModulePage = () => {
})
router.push(`/my/mentorship/programs/${programKey}/modules/${updatedModuleKey}`)
} catch (err) {
handleAppError(err)
let errorMessage = 'Failed to update module. Please try again.'

if (err instanceof Error) {
if (err.message.includes('Permission') || err.message.includes('not have permission')) {
errorMessage =
'You do not have permission to edit this module. Only program admins and assigned mentors can edit modules.'
}
}

addToast({
title: 'Error',
description: errorMessage,
color: 'danger',
variant: 'solid',
timeout: 4000,
})
}
}

Expand Down
Loading