Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
4 changes: 4 additions & 0 deletions backend/src/apps/common/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@
OWASP_NEWS_URL = "https://owasp.org/news"
OWASP_URL = "https://owasp.org"
TAB = "\t"

MIN_SEARCH_QUERY_LENGTH = 3
MAX_SEARCH_QUERY_LENGTH = 100
SEARCH_LIMIT = 5
63 changes: 63 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,11 +2,21 @@

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

from apps.common.constants import (
MAX_SEARCH_QUERY_LENGTH,
MIN_SEARCH_QUERY_LENGTH,
SEARCH_LIMIT,
)
from apps.common.utils import normalize_limit
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
from apps.nest.api.internal.permissions import IsAuthenticated

MAX_LIMIT = 1000


@strawberry.type
Expand Down Expand Up @@ -56,3 +66,56 @@ def user(
.prefetch_related(USER_BADGES_PREFETCH)
.first()
)

@strawberry_django.field(permission_classes=[IsAuthenticated])
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_SEARCH_QUERY_LENGTH
Comment thread
anurag2787 marked this conversation as resolved.
or len(cleaned_query) > MAX_SEARCH_QUERY_LENGTH
):
return []

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

@strawberry_django.field(permission_classes=[IsAuthenticated])
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 (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None:
return []

if project_key:
clean_project_key = project_key.strip().removeprefix("www-project-")
filter_q = Q(
repositorycontributor__repository__project_set__key__iexact=(
f"www-project-{clean_project_key}"
)
)
elif chapter_key:
clean_chapter_key = chapter_key.strip().removeprefix("www-chapter-")
filter_q = Q(
repositorycontributor__repository__key__iexact=(f"www-chapter-{clean_chapter_key}")
)
else:
return []

return list(
User.objects.filter(filter_q)
.annotate(total_contributions=Sum("repositorycontributor__contributions_count"))
.prefetch_related(USER_BADGES_PREFETCH)
.order_by("-total_contributions")[:normalized_limit]
)
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
165 changes: 165 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,165 @@
"""OWASP Certificate GraphQL Mutations."""

import logging
import operator
from functools import reduce

import strawberry
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Q
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


MAX_TITLE_LENGTH = 255


@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 contributors (project or chapter leaders only)."""
user = info.context.request.user
github_user = getattr(user, "github_user", None)

if not github_user or (
not github_user.is_project_leader and not 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)

logins = []
if input_data.recipient_logins:
seen_logins = set()
Comment thread
anurag2787 marked this conversation as resolved.
Outdated
for raw_login in input_data.recipient_logins:
if raw_login and (clean_login := raw_login.strip()):
lower_login = clean_login.lower()
if lower_login not in seen_logins:
seen_logins.add(lower_login)
logins.append(clean_login)
elif input_data.recipient_login and input_data.recipient_login.strip():
logins = [input_data.recipient_login.strip()]

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

title = input_data.title.strip()
if not title:
msg = "Certificate title cannot be empty."
raise ValidationError(msg)

if len(title) > MAX_TITLE_LENGTH:
msg = "Certificate title cannot exceed 255 characters."
raise ValidationError(msg)

clean_project_key = (
input_data.project_key.strip().removeprefix("www-project-")
if input_data.project_key and input_data.project_key.strip()
else None
)
clean_chapter_key = (
input_data.chapter_key.strip().removeprefix("www-chapter-")
if input_data.chapter_key and input_data.chapter_key.strip()
else None
)

if clean_project_key and clean_chapter_key:
msg = "Provide either project or chapter, not both."
raise ValidationError(msg)

if not clean_project_key and not clean_chapter_key:
msg = "Either project or chapter must be provided."
raise ValidationError(msg)

project = None
chapter = None

if clean_project_key:
try:
project = Project.objects.get(key=f"www-project-{clean_project_key}")
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 clean_chapter_key:
try:
chapter = Chapter.objects.get(key=f"www-chapter-{clean_chapter_key}")
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

filter_q = reduce(operator.or_, (Q(login__iexact=login_name) for login_name in logins))
recipients = GithubUser.objects.filter(filter_q)
found = {r.login.lower(): r for r in recipients}

missing = [login_name for login_name in logins if login_name.lower() not in found]
if missing:
msg = (
f"GitHub user '{missing[0]}' not found."
if len(missing) == 1
else f"GitHub users not found: {', '.join(missing)}."
)
logger.warning("GitHub user(s) not found: %s", ", ".join(missing))
raise GraphQLError(
msg,
extensions={"code": "NOT_FOUND", "field": "recipientLogins"},
)

certificates = []
for recipient_login in logins:
recipient = found[recipient_login.lower()]
certificate = Certificate.objects.create(
recipient=recipient,
issuer=github_user,
title=title,
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 ""
Loading
Loading