Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ce14f4c
Update program admins relation
kasya Jan 5, 2026
8aab4c9
Update local compose file db volume
kasya Jan 5, 2026
ad8fec3
Merge branch 'main' into update-program-admins
kasya Jan 7, 2026
5fe73d5
Add Admin model. Update migration and logic.
kasya Jan 11, 2026
8fefe8a
Clean up
kasya Jan 11, 2026
0c2ce63
Merge branch 'update-program-admins' of github.com:OWASP/Nest into up…
kasya Jan 11, 2026
689a68f
Update migration and code
kasya Jan 23, 2026
11714c5
Run make-check
kasya Jan 23, 2026
78fe8db
Merge branch 'main' of github.com:OWASP/Nest into update-program-admins
kasya Jan 23, 2026
ff2bea5
Merge branch 'main' of github.com:OWASP/Nest into update-program-admins
kasya Jan 24, 2026
721bcb2
Fix N+1 issue
kasya Jan 24, 2026
cfea496
Merge branch 'main' of github.com:OWASP/Nest into update-program-admins
kasya Jan 24, 2026
75589a2
Update docstrings
kasya Jan 24, 2026
1ff5927
Revert accidental file push
kasya Jan 24, 2026
ada575b
Merge branch 'main' of github.com:OWASP/Nest into update-program-admins
kasya Jan 26, 2026
601c1ba
Update migration
kasya Jan 28, 2026
d4d1fef
Merge branch 'main' of github.com:OWASP/Nest into update-program-admins
kasya Feb 4, 2026
8d15fc9
Potential fix for pull request finding 'Empty except'
kasya Feb 4, 2026
b31feda
Merge branch 'main' into update-program-admins
kasya Feb 7, 2026
ec6ff17
Merge branch 'main' of github.com:OWASP/Nest into update-program-admins
kasya Feb 15, 2026
120cf2a
Merge branch 'update-program-admins' of github.com:OWASP/Nest into up…
kasya Feb 15, 2026
f3ba9ae
Update code
arkid15r Feb 15, 2026
893fe73
Update compose file
kasya Feb 16, 2026
77afa1c
Merge branch 'main' into update-program-admins
kasya Feb 16, 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
1 change: 1 addition & 0 deletions backend/apps/mentorship/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Mentorship app admin."""

from .admin import AdminAdmin
from .issue_user_interest import IssueUserInterest
from .mentee import MenteeAdmin
from .mentee_module import MenteeModuleAdmin
Expand Down
24 changes: 24 additions & 0 deletions backend/apps/mentorship/admin/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Mentorship app Admin model admin."""

from django.contrib import admin

from apps.mentorship.models.admin import Admin


class AdminAdmin(admin.ModelAdmin):
"""Admin view for Admin model."""

autocomplete_fields = (
"github_user",
"nest_user",
)

list_display = ("github_user",)

search_fields = (
"github_user__login",
"github_user__name",
)


admin.site.register(Admin, AdminAdmin)
12 changes: 12 additions & 0 deletions backend/apps/mentorship/admin/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,26 @@

from django.contrib import admin

from apps.mentorship.models.mentor_module import MentorModule
from apps.mentorship.models.module import Module


class MentorModuleInline(admin.TabularInline):
"""Inline admin for MentorModule through model."""

autocomplete_fields = ("mentor",)
extra = 1
fields = ("mentor",)
model = MentorModule


class ModuleAdmin(admin.ModelAdmin):
"""Admin view for Module model."""

autocomplete_fields = ("issues",)

inlines = (MentorModuleInline,)

list_display = (
"name",
"program",
Expand Down
21 changes: 17 additions & 4 deletions backend/apps/mentorship/admin/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,39 @@
from django.contrib import admin

from apps.mentorship.models import Program
from apps.mentorship.models.program_admin import ProgramAdmin as ProgramAdminThroughModel


class ProgramAdminInline(admin.TabularInline):
"""Inline admin for ProgramAdmin through model."""

autocomplete_fields = ("admin",)

extra = 1

fields = ("admin", "role")

model = ProgramAdminThroughModel


class ProgramAdmin(admin.ModelAdmin):
"""Admin view for Program model."""

inlines = (ProgramAdminInline,)

list_display = (
"name",
"status",
"started_at",
"ended_at",
)

list_filter = ("status",)

search_fields = (
"name",
"description",
)

list_filter = ("status",)

filter_horizontal = ("admins",)


admin.site.register(Program, ProgramAdmin)
69 changes: 27 additions & 42 deletions backend/apps/mentorship/api/internal/mutations/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,11 @@ 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:
msg = "Only mentors can create modules."
raise PermissionDenied(msg) from e

if not program.admins.filter(id=creator_as_mentor.id).exists():
if not program.admins.filter(nest_user=user).exists():
raise PermissionDenied

started_at, ended_at = _validate_module_dates(
Expand Down Expand Up @@ -118,7 +114,6 @@ def create_module(self, info: strawberry.Info, input_data: CreateModuleInput) ->
program.save(update_fields=["experience_levels"])

mentors_to_set = resolve_mentors_from_logins(input_data.mentor_logins or [])
mentors_to_set.add(creator_as_mentor)
module.mentors.set(list(mentors_to_set))

return module
Expand All @@ -145,11 +140,9 @@ def assign_issue_to_user(
if module is None:
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG)

mentor = Mentor.objects.filter(nest_user=user).first()
if mentor is None:
raise PermissionDenied(msg="Only mentors can assign issues.")
if not module.program.admins.filter(id=mentor.id).exists():
raise PermissionDenied
if not Mentor.objects.filter(nest_user=user, modules=module).exists():
msg = "Only mentors of this module can assign issues."
raise PermissionDenied(msg)

gh_user = GithubUser.objects.filter(login=user_login).first()
if gh_user is None:
Expand Down Expand Up @@ -187,11 +180,9 @@ def unassign_issue_from_user(
if module is None:
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG)

mentor = Mentor.objects.filter(nest_user=user).first()
if mentor is None:
raise PermissionDenied
if not module.program.admins.filter(id=mentor.id).exists():
raise PermissionDenied
if not Mentor.objects.filter(nest_user=user, modules=module).exists():
msg = "Only mentors of this module can unassign issues."
raise PermissionDenied(msg)

gh_user = GithubUser.objects.filter(login=user_login).first()
if gh_user is None:
Expand All @@ -217,7 +208,10 @@ def set_task_deadline(
issue_number: int,
deadline_at: datetime,
) -> ModuleNode:
"""Set a deadline for a task. User must be a mentor and an admin of the program."""
"""Set a deadline for a task.

The user must be a mentor of the module.
"""
user = info.context.request.user

module = (
Expand All @@ -228,11 +222,9 @@ def set_task_deadline(
if module is None:
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG)

mentor = Mentor.objects.filter(nest_user=user).first()
if mentor is None:
raise PermissionDenied(msg="Only mentors can set deadlines.")
if not module.program.admins.filter(id=mentor.id).exists():
raise PermissionDenied
if not Mentor.objects.filter(nest_user=user, modules=module).exists():
msg = "Only mentors of this module can set deadlines."
raise PermissionDenied(msg)

issue = (
module.issues.select_related("repository")
Expand Down Expand Up @@ -279,7 +271,10 @@ def clear_task_deadline(
program_key: str,
issue_number: int,
) -> ModuleNode:
"""Clear the deadline for a task. User must be a mentor and an admin of the program."""
"""Clear the deadline for a task.

The user must be a mentor of the module.
"""
user = info.context.request.user

module = (
Expand All @@ -290,11 +285,9 @@ def clear_task_deadline(
if module is None:
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG)

mentor = Mentor.objects.filter(nest_user=user).first()
if mentor is None:
raise PermissionDenied(msg="Only mentors can clear deadlines.")
if not module.program.admins.filter(id=mentor.id).exists():
raise PermissionDenied
if not Mentor.objects.filter(nest_user=user, modules=module).exists():
msg = "Only mentors of this module can clear deadlines."
raise PermissionDenied(msg)

issue = (
module.issues.select_related("repository")
Expand Down Expand Up @@ -326,7 +319,7 @@ 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 = info.context.request.user

try:
Expand All @@ -338,19 +331,11 @@ 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."
logger.warning(
"User '%s' is not a mentor and cannot edit modules.",
user.username,
exc_info=True,
)
raise PermissionDenied(msg) from err

if not module.program.admins.filter(id=creator_as_mentor.id).exists():
raise PermissionDenied
is_admin = module.program.admins.filter(nest_user=user).exists()
is_mentor = Mentor.objects.filter(nest_user=user, modules=module).exists()
if not (is_admin or is_mentor):
msg = "Only admins of the program or mentors of this module can edit modules."
raise PermissionDenied(msg)

started_at, ended_at = _validate_module_dates(
input_data.started_at,
Expand Down
70 changes: 40 additions & 30 deletions backend/apps/mentorship/api/internal/mutations/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,48 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
from django.db import transaction

from apps.mentorship.api.internal.mutations.module import resolve_mentors_from_logins
from apps.github.models import User as GithubUser
from apps.mentorship.api.internal.nodes.enum import ProgramStatusEnum
from apps.mentorship.api.internal.nodes.program import (
CreateProgramInput,
ProgramNode,
UpdateProgramInput,
UpdateProgramStatusInput,
)
from apps.mentorship.models import Mentor, Program
from apps.mentorship.models import Admin, Program
from apps.mentorship.models.program_admin import ProgramAdmin
from apps.nest.api.internal.permissions import IsAuthenticated
from apps.nest.models import User

logger = logging.getLogger(__name__)


def resolve_admins_from_logins(logins: list[str]) -> set:
"""Resolve a list of GitHub logins to a set of Admin objects."""
admins = set()
for login in logins:
try:
github_user = GithubUser.objects.get(login__iexact=login.lower())
admin, _ = Admin.objects.get_or_create(github_user=github_user)
if not admin.nest_user:
try:
nest_user = User.objects.get(github_user=github_user)
admin.nest_user = nest_user
admin.save(update_fields=["nest_user"])
except User.DoesNotExist:
logger.info(
"No Nest user found for GitHub user '%s'; leaving admin.nest_user unset.",
github_user.login,
)
admins.add(admin)
except GithubUser.DoesNotExist as e:
msg = f"GitHub user '{login}' not found."
logger.warning(msg, exc_info=True)
raise ValueError(msg) from e

return admins


@strawberry.type
class ProgramMutation:
"""GraphQL mutations related to program."""
Expand All @@ -30,12 +58,6 @@ def create_program(self, info: strawberry.Info, input_data: CreateProgramInput)
"""Create a new mentorship program."""
user = info.context.request.user

mentor, created = Mentor.objects.get_or_create(
nest_user=user, defaults={"github_user": user.github_user}
)
if created:
logger.info("Created a new mentor profile for user '%s'.", user.username)

if input_data.ended_at <= input_data.started_at:
msg = "End date must be after start date."
logger.warning(
Expand All @@ -57,7 +79,13 @@ def create_program(self, info: strawberry.Info, input_data: CreateProgramInput)
status=ProgramStatusEnum.DRAFT.value,
)

program.admins.set([mentor])
admin, _ = Admin.objects.get_or_create(github_user=user.github_user)
if not admin.nest_user:
admin.nest_user = user
admin.save(update_fields=["nest_user"])
ProgramAdmin.objects.create(
program=program, admin=admin, role=ProgramAdmin.AdminRole.OWNER
)

logger.info(
"User '%s' successfully created program '%s' (ID: %s).",
Expand All @@ -81,18 +109,7 @@ def update_program(self, info: strawberry.Info, input_data: UpdateProgramInput)
logger.warning(msg, exc_info=True)
raise ObjectDoesNotExist(msg) from err

try:
admin = Mentor.objects.get(nest_user=user)
except Mentor.DoesNotExist as err:
msg = "You must be a mentor to update a program."
logger.warning(
"User '%s' is not a mentor and cannot update programs.",
user.username,
exc_info=True,
)
raise PermissionDenied(msg) from err

if not program.admins.filter(id=admin.id).exists():
if not program.admins.filter(nest_user=user).exists():
msg = "You must be an admin of this program to update it."
logger.warning(
"Permission denied for user '%s' to update program '%s'.",
Expand Down Expand Up @@ -130,8 +147,7 @@ def update_program(self, info: strawberry.Info, input_data: UpdateProgramInput)
program.save()

if input_data.admin_logins is not None:
admins_to_set = resolve_mentors_from_logins(input_data.admin_logins)
program.admins.set(admins_to_set)
program.admins.set(resolve_admins_from_logins(input_data.admin_logins))

return program

Expand All @@ -149,13 +165,7 @@ def update_program_status(
msg = f"Program with key '{input_data.key}' not found."
raise ObjectDoesNotExist(msg) from e

try:
mentor = Mentor.objects.get(nest_user=user)
except Mentor.DoesNotExist as e:
msg = "You must be a mentor to update a program."
raise PermissionDenied(msg) from e

if not program.admins.filter(id=mentor.id).exists():
if not program.admins.filter(nest_user=user).exists():
raise PermissionDenied

program.status = input_data.status.value
Expand Down
25 changes: 25 additions & 0 deletions backend/apps/mentorship/api/internal/nodes/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""GraphQL node for Admin model."""

import strawberry


@strawberry.type
class AdminNode:
"""A GraphQL node representing a mentorship admin."""

id: strawberry.ID

@strawberry.field(name="avatarUrl")
def avatar_url(self) -> str:
"""Get the GitHub avatar URL of the admin."""
return self.github_user.avatar_url if self.github_user else ""

@strawberry.field
def login(self) -> str:
"""Get the GitHub login of the admin."""
return self.github_user.login if self.github_user else ""

@strawberry.field
def name(self) -> str:
"""Get the GitHub name of the admin."""
return self.github_user.name if self.github_user else ""
Loading