Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
47 changes: 47 additions & 0 deletions backend/src/apps/github/api/internal/queries/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

import strawberry
import strawberry_django
from django.db.models import Q, Sum

from apps.github.api.internal.nodes.repository import RepositoryNode
from apps.github.api.internal.nodes.user import USER_BADGES_PREFETCH, UserNode
from apps.github.models.repository_contributor import RepositoryContributor
from apps.github.models.user import User

MIN_USER_SEARCH_LENGTH = 2
MAX_USER_SEARCH_LENGTH = 100
USER_SEARCH_LIMIT = 5


@strawberry.type
class UserQuery:
Expand Down Expand Up @@ -56,3 +61,45 @@ def user(
.prefetch_related(USER_BADGES_PREFETCH)
.first()
)

@strawberry_django.field
def search_users(self, query: str) -> list[UserNode]:
Comment thread
anurag2787 marked this conversation as resolved.
"""Search GitHub users by login or name."""
cleaned_query = query.strip()
if (
len(cleaned_query) < MIN_USER_SEARCH_LENGTH
or len(cleaned_query) > MAX_USER_SEARCH_LENGTH
):
return []

return list(
User.objects.filter(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Q(login__icontains=cleaned_query) | Q(name__icontains=cleaned_query)
).order_by("login")[:USER_SEARCH_LIMIT]
)
Comment thread
anurag2787 marked this conversation as resolved.

@strawberry_django.field
def entity_contributors(
self,
project_key: str | None = None,
chapter_key: str | None = None,
limit: int = 15,
Comment thread
anurag2787 marked this conversation as resolved.
) -> list[UserNode]:
"""Fetch top contributors for a project or chapter in a single JOIN query."""
if not project_key and not chapter_key:
return []

if project_key:
filter_q = Q(
repositorycontributor__repository__project__key__iexact=f"www-project-{project_key}"
Comment thread
anurag2787 marked this conversation as resolved.
Outdated
)
else:
filter_q = Q(
repositorycontributor__repository__key__iexact=f"www-chapter-{chapter_key}"
)

return list(
User.objects.filter(filter_q)
.annotate(total_contributions=Sum("repositorycontributor__contributions_count"))
.order_by("-total_contributions")[:limit]
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
)
Comment thread
anurag2787 marked this conversation as resolved.
Outdated
44 changes: 39 additions & 5 deletions backend/src/apps/owasp/admin/certificate.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,51 @@
class CertificateAdmin(admin.ModelAdmin):
"""Admin for Certificate model."""

autocomplete_fields = ("github_user",)
list_display = ("id", "github_user", "tier", "score", "issued_at", "is_revoked")
list_filter = ("tier", "is_revoked", "issued_at")
search_fields = ("github_user__login", "github_user__name", "id")
autocomplete_fields = ("chapter", "issuer", "project", "recipient")
list_display = (
"chapter",
"id",
"is_revoked",
"issued_at",
"issuer",
"project",
"recipient",
"score",
"tier",
"title",
)
list_filter = ("is_revoked", "issued_at", "tier")
list_display_links = ("id",)
search_fields = (
"chapter__key",
"chapter__name",
"id",
"issuer__login",
"issuer__name",
"project__key",
"project__name",
"recipient__login",
"recipient__name",
"title",
)
readonly_fields = ("id", "issued_at", "nest_created_at", "nest_updated_at")

fieldsets = (
(
"Certificate Information",
{
"fields": ("id", "github_user", "tier", "score", "issued_at"),
"fields": (
"chapter",
"id",
"issued_at",
"issuer",
"message",
"project",
"recipient",
"score",
"tier",
"title",
),
},
),
(
Expand Down
3 changes: 3 additions & 0 deletions backend/src/apps/owasp/api/internal/mutations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""OWASP GraphQL mutations."""

from .certificate import CertificateMutation
131 changes: 131 additions & 0 deletions backend/src/apps/owasp/api/internal/mutations/certificate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""OWASP Certificate GraphQL Mutations."""

import logging

import strawberry
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
from django.db import transaction
from graphql import GraphQLError

from apps.github.models.user import User as GithubUser
from apps.nest.api.internal.permissions import IsAuthenticated
from apps.owasp.api.internal.nodes.certificate import CertificateNode
from apps.owasp.models.chapter import Chapter
from apps.owasp.models.crp.certificate import Certificate
from apps.owasp.models.project import Project

logger = logging.getLogger(__name__)


@strawberry.input
class IssueCertificateInput:
"""Input type for issuing a certificate."""

recipient_login: str | None = None
recipient_logins: list[str] | None = None
title: str
message: str = ""
project_key: str | None = None
chapter_key: str | None = None


@strawberry.type
class CertificateMutation:
"""GraphQL mutations related to certificates."""

@strawberry.mutation(permission_classes=[IsAuthenticated])
@transaction.atomic
def issue_certificate(
self, info: strawberry.Info, input_data: IssueCertificateInput
) -> list[CertificateNode]:
"""Issue generic certificates to one or multiple contributors (project/chapter leaders only)."""
user = info.context.request.user

if not user.github_user or (
Comment thread
anurag2787 marked this conversation as resolved.
Outdated
Comment thread
anurag2787 marked this conversation as resolved.
Outdated
not user.github_user.is_project_leader and not user.github_user.chapters.exists()
):
msg = "You must be a project leader or chapter leader to issue certificates."
logger.warning(
"Permission denied for user '%s' to issue a certificate.",
user.username,
)
raise PermissionDenied(msg)
Comment thread
anurag2787 marked this conversation as resolved.
Outdated

logins = []
if input_data.recipient_logins:
logins = [l.strip() for l in input_data.recipient_logins if l and l.strip()]
Comment thread
anurag2787 marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
elif input_data.recipient_login and input_data.recipient_login.strip():
logins = [input_data.recipient_login.strip()]
Comment thread
anurag2787 marked this conversation as resolved.
Outdated

if not logins:
msg = "Recipient login cannot be empty."
raise ValidationError(msg)

if not input_data.title.strip():
msg = "Certificate title cannot be empty."
raise ValidationError(msg)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

if not (input_data.project_key and input_data.project_key.strip()) and not (
input_data.chapter_key and input_data.chapter_key.strip()
):
msg = "Either project or chapter must be provided."
raise ValidationError(msg)
Comment thread
anurag2787 marked this conversation as resolved.
Outdated

project = None
chapter = None

if input_data.project_key:
Comment thread
anurag2787 marked this conversation as resolved.
Outdated
clean_p = input_data.project_key.strip()
try:
project = Project.objects.get(key=f"www-project-{clean_p.replace('www-project-', '')}")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
except Project.DoesNotExist:
try:
project = Project.objects.get(key=clean_p)
except Project.DoesNotExist as err:
msg = f"Project with key '{input_data.project_key}' not found."
raise GraphQLError(
msg,
extensions={"code": "NOT_FOUND", "field": "projectKey"},
) from err

if input_data.chapter_key:
clean_c = input_data.chapter_key.strip()
try:
chapter = Chapter.objects.get(key=f"www-chapter-{clean_c.replace('www-chapter-', '')}")
except Chapter.DoesNotExist:
try:
chapter = Chapter.objects.get(key=clean_c)
except Chapter.DoesNotExist as err:
msg = f"Chapter with key '{input_data.chapter_key}' not found."
raise GraphQLError(
msg,
extensions={"code": "NOT_FOUND", "field": "chapterKey"},
) from err
Comment thread
anurag2787 marked this conversation as resolved.
Outdated

certificates = []
for recipient_login in logins:
try:
recipient = GithubUser.objects.get(login__iexact=recipient_login)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
except GithubUser.DoesNotExist as err:
msg = f"GitHub user '{recipient_login}' not found."
logger.warning("GitHub user '%s' not found.", recipient_login, exc_info=True)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
raise ObjectDoesNotExist(msg) from err
Comment thread
anurag2787 marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

certificate = Certificate.objects.create(
recipient=recipient,
issuer=user.github_user,
title=input_data.title.strip(),
message=input_data.message.strip(),
project=project,
chapter=chapter,
)
Comment thread
anurag2787 marked this conversation as resolved.
certificates.append(certificate)

logger.info(
"User '%s' issued certificate '%s' to '%s'.",
user.username,
certificate.title,
recipient.login,
)

return certificates
41 changes: 37 additions & 4 deletions backend/src/apps/owasp/api/internal/nodes/certificate.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,66 @@
"""OWASP Certificate GraphQL node."""

from typing import TYPE_CHECKING, Annotated

import strawberry
import strawberry_django

from apps.github.api.internal.nodes.user import UserNode
from apps.owasp.models.crp.certificate import Certificate

if TYPE_CHECKING:
from apps.owasp.api.internal.nodes.chapter import ChapterNode
from apps.owasp.api.internal.nodes.project import ProjectNode


@strawberry_django.type(
Certificate,
fields=[
"id",
"issued_at",
"message",
"score",
"title",
],
)
class CertificateNode:
"""Certificate node."""

@strawberry_django.field(select_related=["github_user"])
@strawberry_django.field(select_related=["chapter"])
def chapter(
self, root: Certificate
) -> Annotated["ChapterNode", strawberry.lazy("apps.owasp.api.internal.nodes.chapter")] | None:
"""Resolve associated chapter."""
return root.chapter

@strawberry_django.field(select_related=["recipient"])
Comment thread
anurag2787 marked this conversation as resolved.
def github_user(self, root: Certificate) -> UserNode:
"""Resolve the associated GitHub user."""
return root.github_user
"""Resolve the associated GitHub user (alias for recipient)."""
return root.recipient
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@strawberry_django.field
def is_verified(self, root: Certificate) -> bool:
"""Resolve whether the certificate is active/verified."""
return root.is_verified

@strawberry_django.field(select_related=["issuer"])
def issuer(self, root: Certificate) -> UserNode | None:
"""Resolve the issuer user."""
return root.issuer

@strawberry_django.field(select_related=["project"])
def project(
self, root: Certificate
) -> Annotated["ProjectNode", strawberry.lazy("apps.owasp.api.internal.nodes.project")] | None:
"""Resolve associated project."""
return root.project

@strawberry_django.field(select_related=["recipient"])
def recipient(self, root: Certificate) -> UserNode:
"""Resolve the recipient user."""
return root.recipient

@strawberry_django.field
def tier(self, root: Certificate) -> str:
"""Resolve the human-readable tier level (e.g. 'Level 1')."""
return root.get_tier_display()
return root.get_tier_display() if root.tier else ""
12 changes: 9 additions & 3 deletions backend/src/apps/owasp/api/internal/queries/certificate.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ def certificate(self, certificate_id: str) -> CertificateNode | None:

try:
return Certificate.objects.select_related(
"github_user",
"chapter",
"issuer",
"project",
"recipient",
).get(id=certificate_id)
except Certificate.DoesNotExist:
return None
Expand All @@ -44,8 +47,11 @@ def my_certificates(self, info: strawberry.types.Info) -> list[CertificateNode]:

return (
Certificate.objects.select_related(
"github_user",
"chapter",
"issuer",
"project",
"recipient",
)
.filter(github_user=user.github_user, is_revoked=False)
.filter(recipient=user.github_user, is_revoked=False)
.order_by("-issued_at")
)
28 changes: 28 additions & 0 deletions backend/src/apps/owasp/api/internal/queries/chapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
import strawberry_django

from apps.common.utils import normalize_limit
from apps.github.models.user import User as GithubUser
from apps.owasp.api.internal.nodes.chapter import ChapterNode
from apps.owasp.models.chapter import Chapter

MIN_SEARCH_QUERY_LENGTH = 3
MAX_SEARCH_QUERY_LENGTH = 100
SEARCH_CHAPTERS_LIMIT = 8
Comment thread
anurag2787 marked this conversation as resolved.
Outdated
MAX_LIMIT = 1000


Expand Down Expand Up @@ -38,3 +42,27 @@ def recent_chapters(self, limit: int = 8) -> list[ChapterNode]:
return []

return Chapter.active_chapters.order_by("-created_at")[:normalized_limit]

@strawberry_django.field
def search_chapters(self, query: str) -> list[ChapterNode]:
Comment thread
anurag2787 marked this conversation as resolved.
"""Search active chapters by name (case-insensitive, partial match)."""
cleaned_query = query.strip()
if (
len(cleaned_query) < MIN_SEARCH_QUERY_LENGTH
or len(cleaned_query) > MAX_SEARCH_QUERY_LENGTH
):
return []

return Chapter.active_chapters.filter(
name__icontains=cleaned_query,
).order_by("name")[:SEARCH_CHAPTERS_LIMIT]

@strawberry_django.field
def is_chapter_leader(self, info: strawberry.Info, login: str) -> bool:
"""Check if a GitHub login is an active, reviewed OWASP chapter leader."""
try:
github_user = GithubUser.objects.get(login=login)
Comment thread
anurag2787 marked this conversation as resolved.
Outdated
except GithubUser.DoesNotExist:
return False

return github_user.chapters.exists()
Comment thread
anurag2787 marked this conversation as resolved.
Loading
Loading