Skip to content
153 changes: 84 additions & 69 deletions backend/apps/mentorship/api/internal/nodes/module.py
Original file line number Diff line number Diff line change
@@ -1,92 +1,108 @@
"""GraphQL nodes for Module model."""

from datetime import datetime
from __future__ import annotations

from datetime import datetime # noqa: TC003

import strawberry
import strawberry_django

from apps.common.utils import normalize_limit
from apps.github.api.internal.nodes.issue import MERGED_PULL_REQUESTS_PREFETCH, IssueNode
from apps.github.api.internal.nodes.pull_request import PullRequestNode
from apps.github.api.internal.nodes.user import UserNode
from apps.github.api.internal.nodes.issue import (
MERGED_PULL_REQUESTS_PREFETCH,
IssueNode,
)
from apps.github.api.internal.nodes.pull_request import PullRequestNode # noqa: TC001
from apps.github.api.internal.nodes.user import UserNode # noqa: TC001
from apps.github.models import Label
from apps.github.models.pull_request import PullRequest
from apps.github.models.user import User
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.nodes.enum import ExperienceLevelEnum # noqa: TC001
from apps.mentorship.api.internal.nodes.mentor import MentorNode # noqa: TC001
from apps.mentorship.api.internal.nodes.program import ProgramNode # noqa: TC001
from apps.mentorship.models.issue_user_interest import IssueUserInterest
from apps.mentorship.models.module import Module
from apps.mentorship.models.task import Task

# TC001/TC003: These imports must stay at runtime. Strawberry GraphQL introspects
# type annotations when building the schema; moving them under TYPE_CHECKING would
# cause UnresolvedFieldTypeError at startup.

MAX_LIMIT = 1000


@strawberry.type
@strawberry_django.type(
Module,
fields=[
"description",
"domains",
"ended_at",
"experience_level",
"id",
"key",
"labels",
"name",
"started_at",
"tags",
],
)
class ModuleNode:
"""A GraphQL node representing a mentorship module."""

# TODO (@arkid15r): migrate to decorator for consistency.
@strawberry_django.field
def program(self, root: Module) -> ProgramNode | None:
"""Get the program for this module."""
return root.program

id: strawberry.ID
key: str
name: str
description: str
domains: list[str] | None = None
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
tags: list[str] | None = None
@strawberry_django.field
def project_id(self, root: Module) -> strawberry.ID | None:
"""Get the project ID for this module."""
return root.project_id

@strawberry.field
def mentors(self) -> list[MentorNode]:
@strawberry_django.field
def project_name(self, root: Module) -> str | None:
"""Get the project name for this module."""
return root.project.name if root.project else None

@strawberry_django.field
def mentors(self, root: Module) -> list[MentorNode]:
"""Get the list of mentors for this module."""
return self.mentors.all()
return root.mentors.all()

@strawberry.field
def mentees(self) -> list[UserNode]:
@strawberry_django.field
def mentees(self, root: Module) -> list[UserNode]:
"""Get the list of mentees for this module."""
mentee_users = (
self.menteemodule_set.select_related("mentee__github_user")
root.menteemodule_set.select_related("mentee__github_user")
.filter(mentee__github_user__isnull=False)
.values_list("mentee__github_user", flat=True)
)

return list(User.objects.filter(id__in=mentee_users).order_by("login"))

@strawberry.field
def issue_mentees(self, issue_number: int) -> list[UserNode]:
@strawberry_django.field
def issue_mentees(self, root: Module, 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))
if not issue_ids:
if not (issue_ids := root.issues.filter(number=issue_number).values_list("id", flat=True)):
return []
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# Get mentees assigned to tasks for this issue
mentee_users = (
Task.objects.filter(module=self, issue_id__in=issue_ids, assignee__isnull=False)
Task.objects.filter(module=root, issue_id__in=issue_ids, assignee__isnull=False)
.select_related("assignee")
.values_list("assignee", flat=True)
.distinct()
)

return list(User.objects.filter(id__in=mentee_users).order_by("login"))

@strawberry.field
def project_name(self) -> str | None:
"""Get the project name for this module."""
return self.project.name if self.project else None

@strawberry.field
@strawberry_django.field
def issues(
self, limit: int = 20, offset: int = 0, label: str | None = None
self, root: Module, limit: int = 20, offset: int = 0, label: str | None = None
) -> list[IssueNode]:
"""Return paginated issues linked to this module, optionally filtered by label."""
if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None:
return []

queryset = self.issues.select_related("repository", "author").prefetch_related(
queryset = root.issues.select_related("repository", "author").prefetch_related(
"assignees",
"labels",
MERGED_PULL_REQUESTS_PREFETCH,
Expand All @@ -97,32 +113,30 @@ def issues(

return list(queryset.order_by("-updated_at")[offset : offset + normalized_limit])

@strawberry.field
def issues_count(self, label: str | None = None) -> int:
@strawberry_django.field
def issues_count(self, root: Module, label: str | None = None) -> int:
"""Return total count of issues linked to this module, optionally filtered by label."""
queryset = self.issues

queryset = root.issues
if label and label != "all":
queryset = queryset.filter(labels__name=label)

return queryset.count()

@strawberry.field
def available_labels(self) -> list[str]:
@strawberry_django.field
def available_labels(self, root: Module) -> list[str]:
"""Return all unique labels from issues linked to this module."""
label_names = (
Label.objects.filter(issue__mentorship_modules=self)
Label.objects.filter(issue__mentorship_modules=root)
.values_list("name", flat=True)
.distinct()
)

return sorted(label_names)

@strawberry.field
def issue_by_number(self, number: int) -> IssueNode | None:
@strawberry_django.field
def issue_by_number(self, root: Module, number: int) -> IssueNode | None:
"""Return a single issue by its GitHub number within this module's linked issues."""
return (
self.issues.select_related("repository", "author")
root.issues.select_related("repository", "author")
.prefetch_related(
"assignees",
"labels",
Expand All @@ -132,25 +146,25 @@ def issue_by_number(self, number: int) -> IssueNode | None:
.first()
)

@strawberry.field
def interested_users(self, issue_number: int) -> list[UserNode]:
@strawberry_django.field
def interested_users(self, root: Module, 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))
if not issue_ids:
if not (issue_ids := root.issues.filter(number=issue_number).values_list("id", flat=True)):
return []

interests = (
IssueUserInterest.objects.select_related("user")
.filter(module=self, issue_id__in=issue_ids)
.filter(module=root, issue_id__in=issue_ids)
.order_by("user__login")
)
return [i.user for i in interests]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

@strawberry.field
def task_deadline(self, issue_number: int) -> datetime | None:
@strawberry_django.field
def task_deadline(self, root: Module, issue_number: int) -> datetime | None:
"""Return the deadline for the latest assigned task linked to this module and issue."""
return (
Task.objects.filter(
module=self,
module=root,
issue__number=issue_number,
deadline_at__isnull=False,
)
Expand All @@ -159,12 +173,12 @@ def task_deadline(self, issue_number: int) -> datetime | None:
.first()
)

@strawberry.field
def task_assigned_at(self, issue_number: int) -> datetime | None:
@strawberry_django.field
def task_assigned_at(self, root: Module, issue_number: int) -> datetime | None:
"""Return the latest assignment time for tasks linked to this module and issue number."""
return (
Task.objects.filter(
module=self,
module=root,
issue__number=issue_number,
assigned_at__isnull=False,
)
Expand All @@ -173,15 +187,16 @@ def task_assigned_at(self, issue_number: int) -> datetime | None:
.first()
)

@strawberry.field
def recent_pull_requests(self, limit: int = 5) -> list[PullRequestNode]:
@strawberry_django.field
def recent_pull_requests(self, root: Module, limit: int = 5) -> list[PullRequestNode]:
"""Return recent pull requests linked to issues in this module."""
if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None:
return []

issue_ids = self.issues.values_list("id", flat=True)
return list(
PullRequest.objects.filter(related_issues__id__in=issue_ids)
PullRequest.objects.filter(
related_issues__id__in=root.issues.values_list("id", flat=True)
)
.select_related("author")
.distinct()
.order_by("-created_at")[:normalized_limit]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def _call_module_resolver(instance: object, name: str, *args: object, **kwargs:
field = next((f for f in definition.fields if f.name == name), None)
assert field is not None
assert field.base_resolver is not None
return field.base_resolver.wrapped_func(instance, *args, **kwargs)
return field.base_resolver.wrapped_func(instance, instance, *args, **kwargs)


class FakeModuleNode:
Expand Down