Skip to content
Merged
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
Empty file.
40 changes: 40 additions & 0 deletions backend/certificates/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from django.contrib import admin

from .models import Certificate


@admin.register(Certificate)
class CertificateAdmin(admin.ModelAdmin):
"""Read-mostly admin: atestats are derived, never hand-authored.

Revocation (the only meaningful intervention) happens through the
admin action or the revoke endpoint — both audited. Issuance stays
out of the admin on purpose: a hand-written certificate would break
the "leaderboard-derived" honesty guarantee.
"""

list_display = ("serial", "kind", "user", "contest", "threshold", "status", "issued_at")
list_filter = ("kind", "revoked_at")
search_fields = ("serial", "user__username", "contest__title_i18n")
readonly_fields = (
"uuid",
"serial",
"user",
"kind",
"contest",
"threshold",
"details",
"issued_at",
"revoked_at",
)
actions = ["revoke_action"]

@admin.action(description="Revoke selected certificates (integrity)")
def revoke_action(self, request, queryset):
from django.utils import timezone

count = queryset.filter(revoked_at__isnull=True).update(
revoked_at=timezone.now(),
revoked_reason="revoked by admin action",
)
self.message_user(request, f"{count} certificate(s) revoked.")
8 changes: 8 additions & 0 deletions backend/certificates/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.apps import AppConfig


class CertificatesConfig(AppConfig):
"""Atestat / verifiable certificates (Wave D certifications item)."""

default_auto_field = "django.db.models.BigAutoField"
name = "certificates"
Empty file.
Empty file.
36 changes: 36 additions & 0 deletions backend/certificates/management/commands/issue_certificates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Backfill/audit command for Wave D atestats (idempotent).

Run once when the feature deploys (derives every historical rated-contest
participation, rating milestone and solve milestone), and after any manual
data surgery that bypassed the event hooks. Safe to run repeatedly —
issuance is get_or_create under per-kind uniqueness.

Usage:
python manage.py issue_certificates # derive, no notifications
python manage.py issue_certificates --notify # also notify holders
"""

from django.core.management.base import BaseCommand

from certificates.services import backfill


class Command(BaseCommand):
help = "Idempotently derive contest/rating/solve atestats from history."

def add_arguments(self, parser):
parser.add_argument(
"--notify",
action="store_true",
help="Notify newly-certified holders (off by default: replaying "
"history must never spam).",
)

def handle(self, *args, **options):
counts = backfill(notify_users=options["notify"])
total = sum(counts.values())
for key, value in counts.items():
self.stdout.write(f"{key}: {value} issued")
self.stdout.write(
self.style.SUCCESS(f"done — {total} new certificate(s); existing rows untouched")
)
148 changes: 148 additions & 0 deletions backend/certificates/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Generated by Django 5.2.17 on 2026-11-14 02:28

import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
("contests", "0005_contest_sponsor"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="Certificate",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
unique=True,
verbose_name="verification id",
),
),
(
"serial",
models.CharField(
editable=False, max_length=20, verbose_name="serial"
),
),
(
"kind",
models.CharField(
choices=[
("CONTEST_PARTICIPATION", "Contest participation"),
("RATING_MILESTONE", "Rating milestone"),
("SOLVE_MILESTONE", "Problems-solved milestone"),
],
max_length=24,
verbose_name="kind",
),
),
(
"threshold",
models.PositiveIntegerField(
blank=True, null=True, verbose_name="milestone threshold"
),
),
(
"details",
models.JSONField(default=dict, verbose_name="achievement snapshot"),
),
(
"issued_at",
models.DateTimeField(auto_now_add=True, verbose_name="issued at"),
),
(
"revoked_at",
models.DateTimeField(
blank=True, null=True, verbose_name="revoked at"
),
),
(
"revoked_reason",
models.CharField(
blank=True, max_length=200, verbose_name="revocation reason"
),
),
(
"contest",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="certificates",
to="contests.contest",
verbose_name="contest",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="certificates",
to=settings.AUTH_USER_MODEL,
verbose_name="holder",
),
),
],
options={
"verbose_name": "certificate",
"verbose_name_plural": "certificates",
"ordering": ["-issued_at", "-id"],
"indexes": [
models.Index(
fields=["user", "-issued_at"],
name="certificate_user_id_488ebb_idx",
),
models.Index(fields=["kind"], name="certificate_kind_60ba3c_idx"),
],
"constraints": [
models.UniqueConstraint(
condition=models.Q(("contest__isnull", False)),
fields=("user", "kind", "contest"),
name="uniq_certificate_contest",
),
models.UniqueConstraint(
condition=models.Q(("threshold__isnull", False)),
fields=("user", "kind", "threshold"),
name="uniq_certificate_threshold",
),
models.CheckConstraint(
condition=models.Q(
models.Q(
("contest__isnull", False),
("kind", "CONTEST_PARTICIPATION"),
),
models.Q(
("kind", "RATING_MILESTONE"),
("threshold__isnull", False),
),
models.Q(
("kind", "SOLVE_MILESTONE"),
("threshold__isnull", False),
),
_connector="OR",
),
name="certificate_kind_has_its_discriminator",
),
],
},
),
]
Empty file.
119 changes: 119 additions & 0 deletions backend/certificates/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Atestat models — Wave D certifications/attestations (the honest variant).

The roadmap reserves certifications for "only with brand recognition; a
leaderboard-derived 'atestat' is the honest variant": every certificate is
DERIVED from activity the platform already attests to (a rated contest you
officially finished, a rating milestone you crossed in a rated contest, a
solve count from the judged archive) — never from a self-paced "skill test"
nobody proctored. Each row is publicly verifiable by UUID
(H-120 verifiable participation certificates) and revocable with an audit
reason (integrity: rollback/DQ tooling).
"""

import uuid

from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _


class Certificate(models.Model):
"""One issued atestat.

Identity/dedup keys by kind:

- ``CONTEST_PARTICIPATION`` — unique per (user, contest): an official
(non-virtual, non-DQ) finisher of a RATED contest, with the final rank
and post-contest rating snapshotted in ``details``.
- ``RATING_MILESTONE`` — unique per (user, threshold): the ladder
thresholds (1200…2000) crossed in rated competition.
- ``SOLVE_MILESTONE`` — unique per (user, threshold): distinct APPROVED
problems solved (10…500), the same derivation the leaderboard uses.
"""

class Kind(models.TextChoices):
CONTEST_PARTICIPATION = "CONTEST_PARTICIPATION", _("Contest participation")
RATING_MILESTONE = "RATING_MILESTONE", _("Rating milestone")
SOLVE_MILESTONE = "SOLVE_MILESTONE", _("Problems-solved milestone")

#: Public verification id — printed on the atestat; the verify endpoint
#: is anonymous so employers/teachers can check it without an account.
uuid = models.UUIDField(
_("verification id"), default=uuid.uuid4, editable=False, unique=True
)
#: Human serial ``WC-YYYY-NNNNNN`` (allocated from the row pk on issue).
serial = models.CharField(_("serial"), max_length=20, editable=False)

user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="certificates",
verbose_name=_("holder"),
)
kind = models.CharField(_("kind"), max_length=24, choices=Kind.choices)

#: Discriminators for the per-kind uniqueness above (exactly one of
#: contest/threshold is set per kind; PROTECT keeps issued atestats
#: answerable even if a contest row were ever removed).
contest = models.ForeignKey(
"contests.Contest",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="certificates",
verbose_name=_("contest"),
)
threshold = models.PositiveIntegerField(
_("milestone threshold"), null=True, blank=True
)

#: Immutable achievement snapshot at issue time (rank, rating, solve
#: count, contest title/slug) — the atestat must not change meaning
#: when the live objects are later edited.
details = models.JSONField(_("achievement snapshot"), default=dict)

issued_at = models.DateTimeField(_("issued at"), auto_now_add=True)

#: Revocation (integrity): a rolled-back rating, a post-contest DQ, or
#: an admin action flips the atestat to REVOKED — verification keeps
#: answering, honestly, that the achievement no longer stands.
revoked_at = models.DateTimeField(_("revoked at"), null=True, blank=True)
revoked_reason = models.CharField(_("revocation reason"), max_length=200, blank=True)

class Meta:
verbose_name = _("certificate")
verbose_name_plural = _("certificates")
ordering = ["-issued_at", "-id"]
constraints = [
models.UniqueConstraint(
fields=["user", "kind", "contest"],
condition=models.Q(contest__isnull=False),
name="uniq_certificate_contest",
),
models.UniqueConstraint(
fields=["user", "kind", "threshold"],
condition=models.Q(threshold__isnull=False),
name="uniq_certificate_threshold",
),
models.CheckConstraint(
condition=models.Q(kind="CONTEST_PARTICIPATION", contest__isnull=False)
| models.Q(kind="RATING_MILESTONE", threshold__isnull=False)
| models.Q(kind="SOLVE_MILESTONE", threshold__isnull=False),
name="certificate_kind_has_its_discriminator",
),
]
indexes = [
models.Index(fields=["user", "-issued_at"]),
models.Index(fields=["kind"]),
]

def __str__(self):
return f"{self.serial} {self.get_kind_display()} for {self.user_id}"

@property
def is_revoked(self) -> bool:
return self.revoked_at is not None

@property
def status(self) -> str:
return "REVOKED" if self.is_revoked else "VALID"
33 changes: 33 additions & 0 deletions backend/certificates/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from rest_framework import serializers

from .models import Certificate


class CertificateSerializer(serializers.ModelSerializer):
"""The atestat as the holder (or a verifier) sees it.

``kind`` stays a stable code — the frontend translates it; ``details``
is the frozen achievement snapshot (rank/rating/solve count + the
contest title in both locales).
"""

status = serializers.ReadOnlyField()
holder = serializers.SerializerMethodField()

class Meta:
model = Certificate
fields = [
"uuid",
"serial",
"kind",
"status",
"holder",
"details",
"issued_at",
"revoked_at",
"revoked_reason",
]
read_only_fields = fields

def get_holder(self, obj) -> str:
return obj.user.username
Loading
Loading