Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
36 changes: 32 additions & 4 deletions backend/src/apps/owasp/admin/certificate.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,45 @@
class CertificateAdmin(admin.ModelAdmin):
"""Admin for Certificate model."""

autocomplete_fields = ("github_user",)
list_display = ("id", "github_user", "tier", "score", "issued_at", "is_revoked")
autocomplete_fields = ("recipient", "issuer", "project", "chapter")
list_display = (
"id",
"recipient",
"title",
"project",
"chapter",
"issuer",
"tier",
"score",
"issued_at",
"is_revoked",
)
list_filter = ("tier", "is_revoked", "issued_at")
search_fields = ("github_user__login", "github_user__name", "id")
search_fields = (
"recipient__login",
"recipient__name",
"issuer__login",
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"title",
"id",
)
readonly_fields = ("id", "issued_at", "nest_created_at", "nest_updated_at")

fieldsets = (
(
"Certificate Information",
{
"fields": ("id", "github_user", "tier", "score", "issued_at"),
"fields": (
"id",
"recipient",
"issuer",
"title",
"message",
"project",
"chapter",
"tier",
"score",
"issued_at",
),
},
),
(
Expand Down
51 changes: 42 additions & 9 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
def tier(self, root: Certificate) -> str:
"""Resolve the human-readable tier level (e.g. 'Level 1')."""
return root.get_tier_display()
@strawberry_django.field(select_related=["recipient"])
def recipient(self, root: Certificate) -> UserNode:
"""Resolve the recipient user."""
return root.recipient

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

@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=["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
def is_verified(self, root: Certificate) -> bool:
"""Resolve whether the certificate is active/verified."""
return not root.is_revoked
return root.is_verified

@strawberry_django.field(select_related=["github_user"])
def github_user(self, root: Certificate) -> UserNode:
"""Resolve the associated GitHub user."""
return root.github_user
@strawberry_django.field
def tier(self, root: Certificate) -> str | None:
"""Resolve the human-readable tier level (e.g. 'Level 1')."""
return root.get_tier_display() if root.tier else None
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
32 changes: 25 additions & 7 deletions backend/src/apps/owasp/api/internal/queries/certificate.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
"""OWASP certificate GraphQL queries."""

import re

import strawberry
import strawberry_django
from django.core.exceptions import ValidationError

from apps.nest.api.internal.permissions import IsAuthenticated
from apps.owasp.api.internal.nodes.certificate import CertificateNode
from apps.owasp.models.crp.certificate import Certificate
from apps.owasp.models.crp.certificate import (
CERTIFICATE_ID_ALPHABET,
CERTIFICATE_ID_LENGTH,
Certificate,
)

CERTIFICATE_ID_RE = re.compile(
rf"^[{re.escape(CERTIFICATE_ID_ALPHABET)}]{{{CERTIFICATE_ID_LENGTH}}}$"
)


@strawberry.type
Expand All @@ -15,12 +24,18 @@ class CertificateQuery:

@strawberry_django.field
def certificate(self, certificate_id: str) -> CertificateNode | None:
"""Resolve certificate by raw ID."""
"""Resolve certificate by ID."""
if not CERTIFICATE_ID_RE.fullmatch(certificate_id):
Comment thread
anurag2787 marked this conversation as resolved.
return None

try:
return Certificate.objects.select_related(
"github_user",
"recipient",
"issuer",
"project",
"chapter",
).get(id=certificate_id)
except (Certificate.DoesNotExist, ValidationError, ValueError):
except Certificate.DoesNotExist:
return None

@strawberry_django.field(permission_classes=[IsAuthenticated])
Expand All @@ -32,8 +47,11 @@ def my_certificates(self, info: strawberry.types.Info) -> list[CertificateNode]:

return (
Certificate.objects.select_related(
"github_user",
"recipient",
"issuer",
"project",
"chapter",
)
.filter(github_user=user.github_user, is_revoked=False)
.filter(recipient=user.github_user, is_revoked=False)
.order_by("-issued_at")
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Generated by Django 6.0.8 on 2026-08-13 09:03

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("github", "0044_user_indexes"),
("owasp", "0075_alter_certificate_id"),
]

operations = [
migrations.RemoveConstraint(
model_name="certificate",
name="unique_active_cert_per_tier",
),
migrations.AddField(
model_name="certificate",
name="chapter",
field=models.ForeignKey(
blank=True,
help_text="Associated chapter",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="certificates",
to="owasp.chapter",
),
),
migrations.AddField(
model_name="certificate",
name="issuer",
field=models.ForeignKey(
blank=True,
help_text="Issuer GitHub user (for generic certificates)",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="issued_certificates",
to="github.user",
),
),
migrations.AddField(
model_name="certificate",
name="message",
field=models.TextField(
blank=True,
default="",
help_text="Customizable certificate message",
verbose_name="Message",
),
),
migrations.AddField(
model_name="certificate",
name="project",
field=models.ForeignKey(
blank=True,
help_text="Associated project",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="certificates",
to="owasp.project",
),
),
migrations.RenameField(
model_name="certificate",
old_name="github_user",
new_name="recipient",
),
migrations.AddField(
model_name="certificate",
name="title",
field=models.CharField(
blank=True,
default="",
help_text="Certificate title",
max_length=255,
verbose_name="Title",
),
),
migrations.AlterField(
model_name="certificate",
name="score",
field=models.PositiveIntegerField(
blank=True,
help_text="The contributor's score when the certificate was issued",
null=True,
verbose_name="Score",
),
),
migrations.AlterField(
model_name="certificate",
name="tier",
field=models.CharField(
blank=True,
choices=[
("level_1", "Level 1"),
("level_2", "Level 2"),
("level_3", "Level 3"),
("level_4", "Level 4"),
],
default="",
help_text="The tier at which the certificate was issued",
max_length=20,
verbose_name="Tier",
),
),
migrations.AddConstraint(
model_name="certificate",
constraint=models.UniqueConstraint(
condition=models.Q(("is_revoked", False), models.Q(("tier", ""), _negated=True)),
fields=("recipient", "tier"),
name="unique_active_cert_per_tier",
violation_error_message="Cannot have multiple active certificates for same tier",
),
),
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading