diff --git a/backend/src/apps/owasp/admin/certificate.py b/backend/src/apps/owasp/admin/certificate.py index a83c01e582..a862786a10 100644 --- a/backend/src/apps/owasp/admin/certificate.py +++ b/backend/src/apps/owasp/admin/certificate.py @@ -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", + ), }, ), ( diff --git a/backend/src/apps/owasp/api/internal/nodes/certificate.py b/backend/src/apps/owasp/api/internal/nodes/certificate.py index 7ed2c8bf00..7f8ec0ab47 100644 --- a/backend/src/apps/owasp/api/internal/nodes/certificate.py +++ b/backend/src/apps/owasp/api/internal/nodes/certificate.py @@ -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"]) 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 @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 "" diff --git a/backend/src/apps/owasp/api/internal/queries/certificate.py b/backend/src/apps/owasp/api/internal/queries/certificate.py index 1d8ee18dd5..1060aae729 100644 --- a/backend/src/apps/owasp/api/internal/queries/certificate.py +++ b/backend/src/apps/owasp/api/internal/queries/certificate.py @@ -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 @@ -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") ) diff --git a/backend/src/apps/owasp/migrations/0076_remove_certificate_unique_active_cert_per_tier_and_more.py b/backend/src/apps/owasp/migrations/0076_remove_certificate_unique_active_cert_per_tier_and_more.py new file mode 100644 index 0000000000..103995725d --- /dev/null +++ b/backend/src/apps/owasp/migrations/0076_remove_certificate_unique_active_cert_per_tier_and_more.py @@ -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.SET_NULL, + 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", + ), + ), + ] diff --git a/backend/src/apps/owasp/models/crp/certificate.py b/backend/src/apps/owasp/models/crp/certificate.py index 1053a90866..84c52e5f51 100644 --- a/backend/src/apps/owasp/models/crp/certificate.py +++ b/backend/src/apps/owasp/models/crp/certificate.py @@ -44,8 +44,8 @@ class Meta: ] constraints = [ models.UniqueConstraint( - fields=["github_user", "tier"], - condition=Q(is_revoked=False), + fields=["recipient", "tier"], + condition=Q(is_revoked=False) & ~Q(tier=""), name="unique_active_cert_per_tier", violation_error_message="Cannot have multiple active certificates for same tier", ), @@ -58,20 +58,61 @@ class Meta: editable=False, verbose_name="Certificate ID", ) - github_user = models.ForeignKey( + recipient = models.ForeignKey( User, on_delete=models.CASCADE, related_name="certificates", - help_text="Associated GitHub user", + help_text="Recipient GitHub user", + ) + issuer = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="issued_certificates", + blank=True, + null=True, + help_text="Issuer GitHub user (for generic certificates)", + ) + title = models.CharField( + verbose_name="Title", + max_length=255, + blank=True, + default="", + help_text="Certificate title", + ) + message = models.TextField( + verbose_name="Message", + blank=True, + default="", + help_text="Customizable certificate message", + ) + project = models.ForeignKey( + "owasp.Project", + on_delete=models.SET_NULL, + related_name="certificates", + blank=True, + null=True, + help_text="Associated project", + ) + chapter = models.ForeignKey( + "owasp.Chapter", + on_delete=models.SET_NULL, + related_name="certificates", + blank=True, + null=True, + help_text="Associated chapter", ) tier = models.CharField( verbose_name="Tier", max_length=20, choices=TierChoices.choices, + blank=True, + default="", help_text="The tier at which the certificate was issued", ) score = models.PositiveIntegerField( verbose_name="Score", + blank=True, + null=True, help_text="The contributor's score when the certificate was issued", ) issued_at = models.DateTimeField( @@ -93,7 +134,11 @@ def is_verified(self) -> bool: def __str__(self) -> str: """Return human-readable representation.""" status = "Revoked" if self.is_revoked else "Active" - return f"{self.github_user.login} - {self.tier.upper()} Certificate ({status})" + cert_type = self.title or ( + f"{self.tier.upper()} Certificate" if self.tier else "Certificate" + ) + recipient_name = self.recipient.login if self.recipient else "No Recipient" + return f"{recipient_name} - {cert_type} ({status})" @classmethod @transaction.atomic @@ -117,7 +162,7 @@ def issue_certificate(cls, user: User, score: int, tier: TierChoices) -> None: # Check if user already has an active certificate for this specific tier if cls.objects.filter( - github_user=user, + recipient=user, tier=tier, is_revoked=False, ).exists(): diff --git a/backend/src/apps/owasp/utils/certificate_provider.py b/backend/src/apps/owasp/utils/certificate_provider.py index 0550b2c405..e469e3da8c 100644 --- a/backend/src/apps/owasp/utils/certificate_provider.py +++ b/backend/src/apps/owasp/utils/certificate_provider.py @@ -42,7 +42,7 @@ def issue_certificate(self, user: User, score: int, tier: TierChoices) -> None: from apps.owasp.models.crp.certificate import Certificate # noqa: PLC0415 Certificate.objects.create( - github_user=user, + recipient=user, score=score, tier=tier, )