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

Check warning on line 1 in backend/certificates/admin.py

View workflow job for this annotation

GitHub Actions / quality / quality

Import "django.contrib" could not be resolved (reportMissingImports)

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

Check warning on line 42 in backend/certificates/admin.py

View workflow job for this annotation

GitHub Actions / quality / quality

Import "django.utils" could not be resolved (reportMissingImports)

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

Check warning on line 1 in backend/certificates/apps.py

View workflow job for this annotation

GitHub Actions / quality / quality

Import "django.apps" could not be resolved (reportMissingImports)


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

default_auto_field = "django.db.models.BigAutoField"
name = "certificates"
Empty file.
Empty file.
38 changes: 38 additions & 0 deletions backend/certificates/management/commands/issue_certificates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""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

Check warning on line 13 in backend/certificates/management/commands/issue_certificates.py

View workflow job for this annotation

GitHub Actions / quality / quality

Import "django.core.management.base" could not be resolved (reportMissingImports)

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"
)
)
147 changes: 147 additions & 0 deletions backend/certificates/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# 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.
121 changes: 121 additions & 0 deletions backend/certificates/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""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

Check warning on line 15 in backend/certificates/models.py

View workflow job for this annotation

GitHub Actions / quality / quality

Import "django.conf" could not be resolved (reportMissingImports)
from django.db import models

Check warning on line 16 in backend/certificates/models.py

View workflow job for this annotation

GitHub Actions / quality / quality

Import "django.db" could not be resolved (reportMissingImports)
from django.utils.translation import gettext_lazy as _

Check warning on line 17 in backend/certificates/models.py

View workflow job for this annotation

GitHub Actions / quality / quality

Import "django.utils.translation" could not be resolved (reportMissingImports)


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"
Loading
Loading