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
2 changes: 1 addition & 1 deletion backend/src/apps/github/api/internal/nodes/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def task_deadline(self, root: Issue, info: Info) -> datetime | None:
"""Return the deadline for the latest assigned task linked to this issue."""
mapping = getattr(info.context, "task_deadlines_by_issue", None)
if mapping is not None:
return mapping.get(root.number)
return mapping.get(root.pk)

return (
Task.objects.filter(
Expand Down
20 changes: 11 additions & 9 deletions backend/src/apps/mentorship/api/internal/mutations/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from django.db.utils import IntegrityError
from django.utils import timezone
from graphql import GraphQLError
from strawberry.relay import GlobalID

from apps.github.models import User as GithubUser
from apps.mentorship.api.internal.nodes.module import (
Expand All @@ -17,6 +18,7 @@
ReorderModulesInput,
UpdateModuleInput,
)
from apps.mentorship.api.internal.utils import get_issue_pk
from apps.mentorship.models import Mentor, Module, Program
from apps.mentorship.models.issue_user_interest import IssueUserInterest
from apps.mentorship.models.task import Task
Expand Down Expand Up @@ -152,7 +154,7 @@ def assign_issue_to_user(
*,
module_key: str,
program_key: str,
issue_number: int,
issue_id: GlobalID,
user_login: str,
) -> ModuleNode:
"""Assign an issue to a user by updating Issue.assignees within the module scope."""
Expand All @@ -173,7 +175,7 @@ def assign_issue_to_user(
if gh_user is None:
raise ObjectDoesNotExist(ASSIGNEE_NOT_FOUND_MSG)

issue = module.issues.filter(number=issue_number).first()
issue = module.issues.filter(pk=get_issue_pk(issue_id)).first()
if issue is None:
raise ObjectDoesNotExist(ISSUE_NOT_FOUND_MSG)

Expand All @@ -191,7 +193,7 @@ def unassign_issue_from_user(
*,
module_key: str,
program_key: str,
issue_number: int,
issue_id: GlobalID,
user_login: str,
) -> ModuleNode:
"""Unassign an issue from a user by updating Issue.assignees within the module scope."""
Expand All @@ -212,9 +214,9 @@ def unassign_issue_from_user(
if gh_user is None:
raise ObjectDoesNotExist(ASSIGNEE_NOT_FOUND_MSG)

issue = module.issues.filter(number=issue_number).first()
issue = module.issues.filter(pk=get_issue_pk(issue_id)).first()
if issue is None:
msg = f"Issue {issue_number} not found in this module."
msg = "Issue not found in this module."
raise ObjectDoesNotExist(msg)

issue.assignees.remove(gh_user)
Expand All @@ -229,7 +231,7 @@ def set_task_deadline(
*,
module_key: str,
program_key: str,
issue_number: int,
issue_id: GlobalID,
deadline_at: datetime,
) -> ModuleNode:
"""Set a deadline for a task.
Expand Down Expand Up @@ -260,7 +262,7 @@ def set_task_deadline(
issue = (
module.issues.select_related("repository")
.prefetch_related("assignees")
.filter(number=issue_number)
.filter(pk=get_issue_pk(issue_id))
.first()
)
if issue is None:
Expand Down Expand Up @@ -305,7 +307,7 @@ def clear_task_deadline(
*,
module_key: str,
program_key: str,
issue_number: int,
issue_id: GlobalID,
) -> ModuleNode:
"""Clear the deadline for a task.

Expand All @@ -327,7 +329,7 @@ def clear_task_deadline(
issue = (
module.issues.select_related("repository")
.prefetch_related("assignees")
.filter(number=issue_number)
.filter(pk=get_issue_pk(issue_id))
.first()
)
if issue is None:
Expand Down
56 changes: 31 additions & 25 deletions backend/src/apps/mentorship/api/internal/nodes/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from datetime import datetime

import strawberry
from strawberry.relay import GlobalID
from strawberry.types import Info

from apps.common.utils import normalize_limit
Expand All @@ -15,6 +16,7 @@
from apps.mentorship.api.internal.nodes.enum import ExperienceLevelEnum
from apps.mentorship.api.internal.nodes.mentor import MentorNode
from apps.mentorship.api.internal.nodes.program import ProgramNode
from apps.mentorship.api.internal.utils import get_issue_pk
from apps.mentorship.models.issue_user_interest import IssueUserInterest
from apps.mentorship.models.task import Task

Expand Down Expand Up @@ -64,9 +66,11 @@ def mentees(self) -> list[UserNode]:
return list(User.objects.filter(id__in=mentee_users).order_by("login"))

@strawberry.field
def issue_mentees(self, issue_number: int) -> list[UserNode]:
"""Return mentees assigned to this module's issue identified by its number."""
issue_ids = list(self.issues.filter(number=issue_number).values_list("id", flat=True))
def issue_mentees(self, issue_id: GlobalID) -> list[UserNode]:
"""Return mentees assigned to this module's issue identified by its Nest ID."""
issue_ids = list(
self.issues.filter(pk=get_issue_pk(issue_id)).values_list("id", flat=True)
)
if not issue_ids:
return []

Expand Down Expand Up @@ -103,26 +107,26 @@ def issues(
# BULK load data
deadline_rows = (
Task.objects.filter(module=self, deadline_at__isnull=False)
.order_by("issue__number", "-assigned_at")
.values("issue__number", "deadline_at")
.order_by("issue_id", "-assigned_at")
.values("issue_id", "deadline_at")
)
assigned_rows = (
Task.objects.filter(module=self, assigned_at__isnull=False)
.order_by("issue__number", "-assigned_at")
.values("issue__number", "assigned_at")
.order_by("issue_id", "-assigned_at")
.values("issue_id", "assigned_at")
)

deadline_map = {}
assigned_map = {}

for row in deadline_rows:
num = row["issue__number"]
if num not in deadline_map:
deadline_map[num] = row["deadline_at"]
issue_pk = row["issue_id"]
if issue_pk not in deadline_map:
deadline_map[issue_pk] = row["deadline_at"]
for row in assigned_rows:
num = row["issue__number"]
if num not in assigned_map:
assigned_map[num] = row["assigned_at"]
issue_pk = row["issue_id"]
if issue_pk not in assigned_map:
assigned_map[issue_pk] = row["assigned_at"]

info.context.task_deadlines_by_issue = deadline_map
info.context.task_assigned_at_by_issue = assigned_map
Expand Down Expand Up @@ -182,8 +186,8 @@ def available_labels(self) -> list[str]:
return sorted(label_names)

@strawberry.field
def issue_by_number(self, info: Info, number: int) -> IssueNode | None:
"""Return a single issue by its GitHub number within this module's linked issues."""
def issue_by_id(self, info: Info, issue_id: GlobalID) -> IssueNode | None:
"""Return a single issue by its Nest ID within this module's linked issues."""
user = info.context.request.user
is_admin_or_mentor = self.program and (
self.program.has_admin(user) or self.has_mentor(user)
Expand All @@ -201,7 +205,7 @@ def issue_by_number(self, info: Info, number: int) -> IssueNode | None:
"labels",
MERGED_PULL_REQUESTS_PREFETCH,
)
.filter(number=number)
.filter(pk=get_issue_pk(issue_id))
)

if is_mentee and not is_admin_or_mentor:
Expand All @@ -213,9 +217,11 @@ def issue_by_number(self, info: Info, number: int) -> IssueNode | None:
return issues.first()

@strawberry.field
def interested_users(self, issue_number: int) -> list[UserNode]:
"""Return users interested in this module's issue identified by its number."""
issue_ids = list(self.issues.filter(number=issue_number).values_list("id", flat=True))
def interested_users(self, issue_id: GlobalID) -> list[UserNode]:
"""Return users interested in this module's issue identified by its Nest ID."""
issue_ids = list(
self.issues.filter(pk=get_issue_pk(issue_id)).values_list("id", flat=True)
)
if not issue_ids:
return []
interests = (
Expand All @@ -226,17 +232,17 @@ def interested_users(self, issue_number: int) -> list[UserNode]:
return [i.user for i in interests]

@strawberry.field
def task_deadline(self, info: Info, issue_number: int) -> datetime | None:
def task_deadline(self, info: Info, issue_id: GlobalID) -> datetime | None:
"""Return the deadline for the latest assigned task linked to this module and issue."""
mapping = getattr(info.context, "task_deadlines_by_issue", None)
if mapping is not None:
return mapping.get(issue_number)
return mapping.get(get_issue_pk(issue_id))

# fallback (single issue query)
return (
Task.objects.filter(
module=self,
issue__number=issue_number,
issue_id=get_issue_pk(issue_id),
deadline_at__isnull=False,
)
.order_by("-assigned_at")
Expand All @@ -245,16 +251,16 @@ def task_deadline(self, info: Info, issue_number: int) -> datetime | None:
)

@strawberry.field
def task_assigned_at(self, info: Info, issue_number: int) -> datetime | None:
def task_assigned_at(self, info: Info, issue_id: GlobalID) -> datetime | None:
"""Return the latest assignment time for tasks linked to this module and issue."""
mapping = getattr(info.context, "task_assigned_at_by_issue", None)
if mapping is not None:
return mapping.get(issue_number)
return mapping.get(get_issue_pk(issue_id))

return (
Task.objects.filter(
module=self,
issue__number=issue_number,
issue_id=get_issue_pk(issue_id),
assigned_at__isnull=False,
)
.order_by("-assigned_at")
Expand Down
20 changes: 20 additions & 0 deletions backend/src/apps/mentorship/api/internal/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Helpers for mentorship GraphQL issue identifiers."""

from graphql import GraphQLError
from strawberry.relay import GlobalID


def get_issue_pk(issue_id: GlobalID) -> int:
"""Validate a Nest issue Relay ID and return its database primary key."""
if issue_id.type_name != "IssueNode" or not issue_id.node_id.isdecimal():
msg = "Invalid issue ID."
raise GraphQLError(msg)
try:
issue_pk = int(issue_id.node_id)
except ValueError as exc:
msg = "Invalid issue ID."
raise GraphQLError(msg) from exc
if issue_pk > 2**63 - 1:
msg = "Invalid issue ID."
raise GraphQLError(msg)
return issue_pk
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ def test_task_deadline_with_bulk_load_mapping(self):
"""Test task_deadline field when mapping exists with issue deadline."""
expected_date = datetime(2025, 10, 26, tzinfo=UTC)
mock_issue = Mock()
mock_issue.number = 123
mock_issue.number = 42
mock_issue.pk = 123

mock_info = Mock()
mock_info.context = Mock()
Expand All @@ -186,7 +187,8 @@ def test_task_deadline_with_bulk_load_mapping(self):
def test_task_deadline_with_bulk_load_mapping_no_deadline(self):
"""Test task_deadline field when mapping exists but issue not in mapping."""
mock_issue = Mock()
mock_issue.number = 123
mock_issue.number = 42
mock_issue.pk = 123

mock_info = Mock()
mock_info.context = Mock()
Expand All @@ -200,7 +202,8 @@ def test_task_deadline_without_bulk_load_mapping(self):
"""Test task_deadline field when no mapping exists (fallback to query)."""
expected_date = datetime(2025, 10, 26, tzinfo=UTC)
mock_issue = Mock()
mock_issue.number = 123
mock_issue.number = 42
mock_issue.pk = 123

mock_info = Mock()
mock_info.context = Mock()
Expand Down
Loading