Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 44 additions & 0 deletions backend/apps/mentorship/api/internal/mutations/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from apps.mentorship.api.internal.nodes.module import (
CreateModuleInput,
ModuleNode,
ReorderModulesInput,
UpdateModuleInput,
)
from apps.mentorship.models import Mentor, Module, Program
Expand Down Expand Up @@ -453,3 +454,46 @@ def delete_module(
module.delete()

return f"Module '{module_name}' has been deleted successfully."

@strawberry.mutation(permission_classes=[IsAuthenticated])
@transaction.atomic
def reorder_modules(
self, info: strawberry.Info, input_data: ReorderModulesInput
) -> list[ModuleNode]:
"""Reorder modules within a program. User must be a program admin."""
user = info.context.request.user

try:
program = Program.objects.get(key=input_data.program_key)
except Program.DoesNotExist as e:
msg = f"Program with key '{input_data.program_key}' not found."
raise ObjectDoesNotExist(msg) from e

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

if len(set(input_data.module_keys)) != len(input_data.module_keys):
msg = "Duplicate module keys are not allowed."
raise ValidationError(msg)

modules_query = Module.objects.filter(program=program, key__in=input_data.module_keys)

if modules_query.count() != len(input_data.module_keys):
msg = "Provided module keys do not match the program's modules."
raise ValidationError(msg)

modules = list(modules_query.select_for_update())

key_to_order = {key: idx for idx, key in enumerate(input_data.module_keys)}

for module in modules:
module.order = key_to_order[module.key]

Module.objects.bulk_update(modules, ["order"])

return (
Module.objects.filter(program=program)
.select_related("program", "project")
.prefetch_related("mentors__github_user")
.order_by("order", "started_at")
)
9 changes: 9 additions & 0 deletions backend/apps/mentorship/api/internal/nodes/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class ModuleNode:
ended_at: datetime
experience_level: ExperienceLevelEnum
labels: list[str] | None = None
order: int = 0
program: ProgramNode | None = None
project_id: strawberry.ID | None = None
started_at: datetime
Expand Down Expand Up @@ -222,3 +223,11 @@ class UpdateModuleInput:
project_name: str
started_at: datetime
tags: list[str] = strawberry.field(default_factory=list)


@strawberry.input
class ReorderModulesInput:
"""Input for reordering modules within a program."""

program_key: str
module_keys: list[str]
4 changes: 2 additions & 2 deletions backend/apps/mentorship/api/internal/queries/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def get_program_modules(self, program_key: str) -> list[ModuleNode]:
Module.objects.filter(program__key=program_key)
.select_related("program", "project")
.prefetch_related("mentors__github_user")
.order_by("started_at")
.order_by("order", "started_at")
)

@strawberry.field
Expand All @@ -31,7 +31,7 @@ def get_project_modules(self, project_key: str) -> list[ModuleNode]:
Module.objects.filter(project__key=project_key)
.select_related("program", "project")
.prefetch_related("mentors__github_user")
.order_by("started_at")
.order_by("order", "started_at")
)

@strawberry.field
Expand Down
26 changes: 26 additions & 0 deletions backend/apps/mentorship/migrations/0008_module_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("mentorship", "0007_alter_mentor_github_user_alter_mentor_nest_user_and_more"),
]

operations = [
migrations.AddField(
model_name="module",
name="order",
field=models.PositiveSmallIntegerField(
default=0,
help_text="Display order of the module within its program.",
verbose_name="Order",
),
),
migrations.AlterModelOptions(
name="module",
options={
"ordering": ["order", "started_at"],
"verbose_name_plural": "Modules",
},
),
]
14 changes: 14 additions & 0 deletions backend/apps/mentorship/models/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Meta:
"""Model options."""

db_table = "mentorship_modules"
ordering = ["order", "started_at"]
verbose_name_plural = "Modules"
constraints = [
models.UniqueConstraint(
Expand All @@ -47,6 +48,11 @@ class Meta:
blank=True,
default="",
)
order = models.PositiveSmallIntegerField(
default=0,
verbose_name="Order",
help_text="Display order of the module within its program.",
)

# FKs.
labels = models.JSONField(
Expand Down Expand Up @@ -97,5 +103,13 @@ def save(self, *args, **kwargs):
self.started_at = self.started_at or self.program.started_at
self.ended_at = self.ended_at or self.program.ended_at

if not self.pk and self.program:
max_order = (
Module.objects.filter(program=self.program)
.aggregate(max_order=models.Max("order"))
.get("max_order")
)
self.order = (max_order or 0) + 1

self.key = slugify(self.name)
super().save(*args, **kwargs)
Original file line number Diff line number Diff line change
Expand Up @@ -1278,3 +1278,211 @@ def test_update_module_removes_old_experience_level(

assert "advanced" in mock_mod.program.experience_levels
mock_mod.program.save.assert_called_once_with(update_fields=["experience_levels"])


class TestModuleMutationDeleteModule:
"""Tests for ModuleMutation.delete_module."""

def _make_info(self, user):
info = MagicMock()
info.context.request.user = user
return info

@patch("apps.mentorship.api.internal.mutations.module.Module")
def test_delete_module_success_removes_unique_experience_level(self, mock_module):
"""Test successful deletion removes experience level when no other module uses it."""
user = MagicMock()
info = self._make_info(user)

mock_mod = MagicMock()
mock_mod.name = "Test Module"
mock_mod.experience_level = "intermediate"
mock_mod.program.admins.filter.return_value.exists.return_value = True
mock_mod.program.experience_levels = ["beginner", "intermediate"]

mock_module.objects.select_related.return_value.get.return_value = mock_mod
mock_module.DoesNotExist = ObjectDoesNotExist
mock_module.objects.filter.return_value.exclude.return_value.exists.return_value = False

mutation = ModuleMutation()
result = mutation.delete_module(info, program_key="prog-1", module_key="mod-1")

assert result == "Module 'Test Module' has been deleted successfully."
mock_mod.delete.assert_called_once()
mock_mod.program.save.assert_called_once_with(update_fields=["experience_levels"])
assert "intermediate" not in mock_mod.program.experience_levels

@patch("apps.mentorship.api.internal.mutations.module.Module")
def test_delete_module_success_keeps_shared_experience_level(self, mock_module):
"""Test successful deletion keeps experience level when other modules use it."""
user = MagicMock()
info = self._make_info(user)

mock_mod = MagicMock()
mock_mod.name = "Test Module"
mock_mod.experience_level = "beginner"
mock_mod.program.admins.filter.return_value.exists.return_value = True
mock_mod.program.experience_levels = ["beginner", "advanced"]

mock_module.objects.select_related.return_value.get.return_value = mock_mod
mock_module.DoesNotExist = ObjectDoesNotExist
mock_module.objects.filter.return_value.exclude.return_value.exists.return_value = True

mutation = ModuleMutation()
result = mutation.delete_module(info, program_key="prog-1", module_key="mod-1")

assert result == "Module 'Test Module' has been deleted successfully."
mock_mod.delete.assert_called_once()
assert "beginner" in mock_mod.program.experience_levels

@patch("apps.mentorship.api.internal.mutations.module.Module")
def test_delete_module_not_found(self, mock_module):
"""Test ObjectDoesNotExist when module is not found."""
user = MagicMock()
info = self._make_info(user)

mock_module.DoesNotExist = ObjectDoesNotExist
mock_module.objects.select_related.return_value.get.side_effect = ObjectDoesNotExist(
"not found"
)

mutation = ModuleMutation()
with pytest.raises(ObjectDoesNotExist):
mutation.delete_module(info, program_key="prog-1", module_key="mod-1")

@patch("apps.mentorship.api.internal.mutations.module.Module")
def test_delete_module_not_admin(self, mock_module):
"""Test PermissionDenied when user is not a program admin."""
user = MagicMock()
info = self._make_info(user)

mock_mod = MagicMock()
mock_mod.program.admins.filter.return_value.exists.return_value = False

mock_module.objects.select_related.return_value.get.return_value = mock_mod
mock_module.DoesNotExist = ObjectDoesNotExist

mutation = ModuleMutation()
with pytest.raises(PermissionDenied):
mutation.delete_module(info, program_key="prog-1", module_key="mod-1")


class TestModuleMutationReorderModules:
"""Tests for ModuleMutation.reorder_modules."""

def _make_info(self, user):
info = MagicMock()
info.context.request.user = user
return info

def _make_input_data(self, **overrides):
defaults = {
"program_key": "test-program",
"module_keys": ["mod-b", "mod-a", "mod-c"],
}
defaults.update(overrides)
return MagicMock(**defaults)

@patch("apps.mentorship.api.internal.mutations.module.Module")
@patch("apps.mentorship.api.internal.mutations.module.Program")
def test_reorder_modules_success(self, mock_program, mock_module):
"""Test successful module reordering by admin."""
user = MagicMock()
info = self._make_info(user)
input_data = self._make_input_data()

mock_prog = MagicMock()
mock_program.objects.get.return_value = mock_prog
mock_prog.admins.filter.return_value.exists.return_value = True

mod_a = MagicMock()
mod_a.key = "mod-a"
mod_b = MagicMock()
mod_b.key = "mod-b"
mod_c = MagicMock()
mod_c.key = "mod-c"

mock_module.objects.filter.return_value.count.return_value = 3
mock_module.objects.filter.return_value.select_for_update.return_value = [
mod_a,
mod_b,
mod_c,
]

mock_result_qs = MagicMock()
mock_qs = mock_module.objects.filter.return_value.select_related.return_value
mock_qs.prefetch_related.return_value.order_by.return_value = mock_result_qs

mutation = ModuleMutation()
mutation.reorder_modules(info, input_data)

assert mod_b.order == 0
assert mod_a.order == 1
assert mod_c.order == 2
mock_module.objects.bulk_update.assert_called_once()

@patch("apps.mentorship.api.internal.mutations.module.Program")
def test_reorder_modules_not_admin(self, mock_program):
"""Test PermissionDenied when user is not a program admin."""
user = MagicMock()
info = self._make_info(user)
input_data = self._make_input_data()

mock_prog = MagicMock()
mock_program.objects.get.return_value = mock_prog
mock_prog.admins.filter.return_value.exists.return_value = False

mutation = ModuleMutation()
with pytest.raises(PermissionDenied):
mutation.reorder_modules(info, input_data)

@patch("apps.mentorship.api.internal.mutations.module.Program")
def test_reorder_modules_program_not_found(self, mock_program):
"""Test ObjectDoesNotExist when program not found."""
user = MagicMock()
info = self._make_info(user)
input_data = self._make_input_data()

mock_program.DoesNotExist = ObjectDoesNotExist
mock_program.objects.get.side_effect = ObjectDoesNotExist("not found")

mutation = ModuleMutation()
with pytest.raises(ObjectDoesNotExist):
mutation.reorder_modules(info, input_data)

@patch("apps.mentorship.api.internal.mutations.module.Program")
def test_reorder_modules_duplicate_keys(self, mock_program):
"""Test ValidationError when duplicate module keys are provided."""
user = MagicMock()
info = self._make_info(user)
input_data = self._make_input_data(module_keys=["key-a", "key-a", "key-c"])

mock_prog = MagicMock()
mock_program.objects.get.return_value = mock_prog
mock_prog.admins.filter.return_value.exists.return_value = True

mutation = ModuleMutation()
with pytest.raises(ValidationError, match=r"Duplicate module keys are not allowed."):
mutation.reorder_modules(info, input_data)

@patch("apps.mentorship.api.internal.mutations.module.Module")
@patch("apps.mentorship.api.internal.mutations.module.Program")
def test_reorder_modules_mismatched_keys(self, mock_program, mock_module):
"""Test ValidationError when provided keys do not match the program's modules."""
user = MagicMock()
info = self._make_info(user)
input_data = self._make_input_data(module_keys=["key-a", "key-b"])

mock_prog = MagicMock()
mock_program.objects.get.return_value = mock_prog
mock_prog.admins.filter.return_value.exists.return_value = True

mod_a = MagicMock(key="key-a")
mock_module.objects.filter.return_value.count.return_value = 1
mock_module.objects.filter.return_value.select_for_update.return_value = [mod_a]

mutation = ModuleMutation()
with pytest.raises(
ValidationError, match=r"Provided module keys do not match the program's modules."
):
mutation.reorder_modules(info, input_data)
Loading